ドラッグ コンポーザブル

ユーザーが画面上でコンポーネントを物理的にドラッグできるようにすることは、最新の Android アプリの基本です。カスタム スライダー、スワイプして閉じるコンポーネント、インタラクティブなキャンバスのいずれを構築する場合でも、Jetpack Compose にはドラッグ イベントを簡単に処理できる包括的な API セットが用意されています。

Compose のドラッグ API は、未加工のタッチイベントの複雑さを抽象化します。pointerInput を使用してタップ座標を手動で計算し、ジェスチャーのライフサイクルを管理する代わりに、Compose の状態管理システムとアニメーション システムに直接結び付く組み込みの修飾子を使用できます。

このページでは、各ドラッグ API の使用方法と使用するタイミングについて説明します。

適切なドラッグ API を選択する

ドラッグ可能なコンテンツを実装するために使用できる API がいくつかあります。

  • 標準のマテリアル コンポーネント( SwipeToDismissBox やナビゲーション ドロワーなど)を選択する場合: デフォルトのマテリアル 3 のスワイプまたはドラッグ動作(メールの削除、タスクのアーカイブ、サイドメニューやボトムシートを開くなど)が必要な場合。カスタムの状態やレイアウト管理を必要とせずに、組み込みの動作を提供します。
  • Modifier.draggable を選択する状況: 未加工のドラッグ差分のみが必要な場合(カスタム スライダーをドラッグする場合など)。詳細については、ドラッグ可能を使用して単一軸に沿ってドラッグするをご覧ください。
    • 選択 Modifier.draggable2D のタイミング: X 軸と Y 軸の両方に沿って同時に連続的に移動するコンポーネントが必要な場合(たとえば、地図をパンする場合や、ユーザーが画面上の任意の場所にドラッグできるフローティング アクション ボタンを作成する場合)。詳細については、Modifier.draggable2D を使用して 2 次元でドラッグするをご覧ください。
  • Modifier.anchoredDraggable を選択するタイミング: 2 つ以上の個別の位置(ボトムシート、ナビゲーション ドロワー、スワイプして表示するアクションなど)の間でスナップする必要があるカスタム インタラクティブ コンポーネントを構築する場合。詳細については、anchoredDraggable を使用してアンカー ポイント間でコンポーネントをドラッグするをご覧ください。

draggable を使用して 1 つの軸に沿ってドラッグする

draggable 修飾子は、単一方向のドラッグ操作に対する高レベルのエントリ ポイントであり、ドラッグ距離をピクセル単位で報告します。

この修飾子は scrollable と似ていますが、操作の検出のみを行う点に注意してください。状態を保持し、offset 修飾子を使用して要素を移動するなどして、画面に表示する必要があります。

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

ドラッグ操作全体を制御する必要がある場合は、代わりに pointerInput 修飾子を使用してドラッグ操作検出機能を使用することを検討してください。

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

ここに代替テキストを挿入
図 1. 指でドラッグされている UI 要素

anchoredDraggable を使用してアンカー ポイント間でコンポーネントをドラッグする

anchoredDraggable 修飾子を使用すると、アンカー ポイントと呼ばれる一連の事前定義された位置の間でコンポーネントをドラッグできます。ユーザーがドラッグ操作を離すと、コンポーネントは最も近いアンカーに固定されます。anchoredDraggable は、カスタム ボトムシート、複雑なスライダー、ドロワー パネルなどのコンポーネントを作成する場合に便利です。

API サーフェス

AnchoredDraggable API は、主に次の部分で構成されています。

  • アンカー(DraggableAnchors): カスタム状態(Start, Center, End の列挙型クラスなど)と、対応するピクセル オフセット値との間のマッピングを定義するビルダー。
  • 状態(AnchoredDraggableState<T>): コンポーネントの現在の位置(オフセット)、コンポーネントが配置されているアンカー、ドラッグの進行状況を管理する状態ホルダー。構成変更後も状態を保持するには、rememberSaveable と AnchoredDraggableState.Saver<T> を使用して作成する必要があります。
  • ジェスチャー修飾子(Modifier.anchoredDraggable): Orientation に沿ったドラッグ操作を検出し、ドラッグの差分で状態を更新し、フリング操作を処理して、リリース時にコンポーネントを最も近いアンカーにアニメーション化する修飾子。

例: カスタム SwipeToDismiss コンポーネントを構築する

この例では、ユーザーが左にスワイプして閉じたり、右にドラッグしてオーバースクロール ストレッチ効果を表示したりできる、カスタム SwipeToDismiss コンポーネント(白いバナー)を作成する方法を示します。

  1. アンカー値を定義する。ドラッグ可能なコンテンツが収まる個別の位置を表す列挙型を定義します。

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. AnchoredDraggableState をインスタンス化して保存します。rememberSaveable と AnchoredDraggableState.Saver() を使用して、アクティビティの再作成や構成の変更(画面の回転やテーマの変更など)が行われても、コンポーネントの現在の位置を保持します。

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. layout、offset、draggable 修飾子を適用する。コンポーネントをドラッグ可能にし、アンカーポイントがサイズに適応するようにするには、視覚的に移動するコンテンツに次の修飾子を適用します。

    • Modifier.layout: アンカーの位置は、コンポーネントのサイズに依存することがよくあります。このサイズは、最初のコンポジションでは不明です。layout 修飾子を使用してコンポーネントを測定し、その幅に基づいて DraggableAnchors マップを動的に計算して、state.updateAnchors() を使用して状態を更新します。

    • Modifier.anchoredDraggable: 状態に接続し、指定された方向にドラッグ デルタをディスパッチします。また、フリング操作を処理して、コンポーネントを離したときに最も近いアンカーに移動するアニメーションを表示します。この修飾子を、ドラッグ操作に応答するコンポーネントに追加し、state とドラッグ orientation(および、必要に応じて overscrollEffect)を渡します。
    • Modifier.offset: オフセット ラムダ内の state.requireOffset() からオフセットを読み取ります。このラムダ内でオフセットを読み取ると、値は配置フェーズ中にのみ読み取られるため、ドラッグ中に不要な再コンポジションが発生するのを回避できます。この修飾子は、視覚的に移動するコンテンツに適用します。

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [省略可] オーバースクロールを統合する。overscrollEffect API は、スクロールまたはフリングでスクロール コンテナの端に到達したときに表示される視覚効果を実装します。ユーザーが定義されたアンカー境界をドラッグしてオーバースクロール効果(通常はストレッチまたはグロー)を発生させたときに、視覚的なフィードバックを提供できます。

    1. ディスパッチ: overscrollEffect インスタンスを anchoredDraggable 修飾子に渡します。これにより、ジェスチャーがアンカー境界の内側か外側かに応じて、ドラッグ デルタが AnchoredDraggable 状態またはオーバースクロール効果にディスパッチされます。
    2. レンダリング: 同じ OverscrollEffect インスタンスを持つ Modifier.overscroll を、エフェクトを視覚的にレンダリングするコンポーネントに適用します。

次のスニペットは、標準のストレッチ効果を追加する方法を示しています。

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

結果

上記のコード スニペットでは、白いバナーに「Drag me to dismiss!」というテキストが表示されます。ユーザーは左にスワイプしてバナーを閉じることができます(または右にドラッグしてオーバースクロール ストレッチ効果を確認できます)。

動画 2. カスタムの「スワイプして閉じる」コンポーネントの実装。

例: カスタムのジェスチャー駆動型ナビゲーション ドロワーを作成する

この例では、カスタムのジェスチャー駆動型ナビゲーション ドロワーを構築する方法を示します。ユーザーが水平方向にドラッグすると、フォアグラウンド画面(ScreenContents)が右にスライドし、1.0f から 0.8f に縮小され、角が丸くなって、下にあるナビゲーション ドロワーが表示されます。

  1. アンカーの状態と位置を定義する。この例では、アンカーはドロワーの開いた状態と閉じた状態です。Open 状態と Closed 状態の列挙型を定義し、DraggableAnchors を使用してピクセル オフセットにマッピングします。

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. アイテムをクリックしたときに anchoredDraggableState をアニメーション化する。HomeScreenDrawer を Box の下部に、ScreenContents を上部に配置します。ドロワーは静止したままで、最上位レイヤがその上で移動、拡大縮小します。

    ユーザーがドロワー項目を選択したら、アクティブな画面の状態を更新し、state.animateTo(DrawerState.Closed) を使用してドロワーを自動的に閉じます。

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. state.requireOffset() を使用して複数の視覚プロパティを変更する。コンテンツをオフセットするだけでなく、Modifier.graphicsLayer を使用して複数のプロパティを変更し、ドラッグの進行状況に基づいて移動、拡大縮小、角丸を同時にアニメーション化できます。

    • state.requireOffset() を使用して、現在のドラッグ オフセットを取得します。
    • lerp()(線形補間)を使用して、オフセットとドロワーの幅を基準に、比例スケール(1f~0.8f)と角丸(0.dp~32.dp)を計算します。
    • Modifier.graphicsLayer は描画フェーズで変更を実行するため、ドラッグ中に再コンポーズをトリガーすることなく、高パフォーマンスのアニメーションを実現できます。

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. animateTo を使用してプログラムによるアニメーションをトリガーする。コルーチン スコープ内で state.animateTo() を起動して、ドロワーをプログラムで制御します(メニュー アイコンをタップしたときや、ドロワー アイテムを選択したときなど)。

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

結果

動画 3. フォアグラウンドの画面([Sleep])が右にドラッグされ、その下にあるナビゲーション ドロワーが表示されています。

Modifier.draggable2D を使用して 2 つのディメンションをドラッグします。

draggable2D 修飾子を使用して、個々の UI 要素を X 軸と Y 軸の両方に沿って同時に移動します。

コンポーザブル要素をドラッグする

この例は、draggable2D の最も一般的なユースケースを示しています。ユーザーが UI 要素を選択して、親コンテナ内の任意の場所に再配置できるようにします。

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

動画 4. グレーの背景上で小さな紫色のボックスの位置が変更され、ユーザーが指を離した瞬間に要素の移動が停止する、直接的な 2D ドラッグを示しています。

上記のコード スニペットには、次のものが含まれています。

  • offset 状態を使用してボックスの位置を追跡します。
  • offset 修飾子を使用して、ドラッグの差分に基づいてコンポーネントの位置をシフトします。
  • フリングはサポートされていないため、ユーザーが指を離すとボックスの移動は停止します。

親のドラッグ領域に基づいて子コンポーザブルをドラッグする

この例では、draggable2D を使用して、セレクタノブが特定のサーフェス内に制限される 2D 入力領域を作成する方法を示します。コンポーネント自体を移動するドラッグ可能な要素の例とは異なり、この実装では 2D デルタを使用して、カラー選択ツール全体で子コンポーザブル「セレクタ」を移動します。

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

動画 5. 白い円形のセレクタノブが付いたカラー グラデーション。このノブは任意の方向にドラッグできます。2D デルタがコンテナの境界にクランプされ、選択した色の値が更新される仕組みを示しています。

上記のスニペットには、次のものが含まれています。

  • onSizeChanged 修飾子を使用して、グラデーション コンテナの実際のサイズをキャプチャします。セレクタはエッジがどこにあるかを正確に把握しています。
  • graphicsLayer 内で、translationX と translationY を調整して、ドラッグ中にセレクタが中央に配置されるようにします。