ドラッグ コンポーザブル

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

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

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

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

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

  • 標準のマテリアル コンポーネント(SwipeToDismissBox やナビゲーション ドロワーなど)を選択する場合: デフォルトのマテリアル 3 のスワイプまたはドラッグの動作(メールの削除、タスクのアーカイブ、サイドメニューやボトムシートのオープンなど)が必要な場合。カスタムの状態やレイアウトの管理を必要とせずに、組み込みの動作を提供します。
  • Modifier.draggable を選択するタイミング: 未加工のドラッグ 差分のみが必要な場合(カスタム スライダーをドラッグする場合など)。詳細については、draggable を使用して 1 つの軸に沿ってドラッグするをご覧ください。
    • 選択 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 の列挙型クラスなど)と対応するピクセル オフセット値の間のマッピングを定義するビルダー。
  • State(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. フォアグラウンド画面(睡眠)が右にドラッグされ、基盤となるナビゲーション ドロワーが表示されます。

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 を調整します。