Safie Engineers' Blog!

Safieのエンジニアが書くブログです

Jetpack Composeでドラッグ&ドロップで要素の並び替えが可能なリストやグリッドを作る

この記事はSafie Engineers' Blog! Advent Calendar 10日目の記事です

こんにちは!Safie(セーフィー)でモバイルアプリの開発をしている河原(@rui_qma)です。

🚀 はじめに:なぜComposeでドラッグ&ドロップが必要なのか

従来のViewシステムとの壁

AndroidチームではAndroid ViewベースのUIから Jetpack Compose ベースのUIへの移行を進めています。

弊社のプロダクト「Safie Viewer(セーフィー ビューアー)」のカメラ一覧画面には、カメラの順番をドラッグ&ドロップ(D&D)によって並び替える機能があります。

従来のAndroid Viewでは、RecyclerViewItemTouchHelper を組み合わせることで、D&Dによる並び替えが公式APIとして簡単に実現できていました。

しかし、Jetpack Composeでは、2025年11月現在、LazyVerticalGrid(またはLazyColumn)内で同様のD&D並び替え機能を「公式APIだけで」実現するための専用のコンポーネントはまだ提供されていません。(composeBom: 2025.11.01で確認)

本記事では、この「Composeでドラッグ&ドロップによる並び替えが可能なグリッド」を、公式API未提供の中でどのように作り込むか、その設計と実装手法について解説します。

✨ 実現したい機能の要件定義

今回の記事で実装を目指すD&D並び替えグリッドの要件は以下の5点です。

  • 要素を長押ししたタイミングでD&D可能状態にし、触覚フィードバックを実行する。
  • ドラッグに沿って要素を移動させる。
  • 順番の入れ替わりが発生した場合、アニメーションで視覚的に表現する。
  • ドラッグ位置がリストの上下端に達した場合、リスト自体を自動でスクロールする。
  • ドラッグ終了時に要素を定位置に戻す。

これらの機能を、土台となるComposableから段階的に実装していきます。

📦 Step0: 土台となるComposableを用意

今回の設計方針としてLazyVerticalGridをラップするComposableを作成し(DraggableGridと命名)、このComposableの中で状態の管理及びD&D機能の拡張を進めていきます。

@Composable
fun DraggableGrid(
    list: List<String>,
    modifier: Modifier = Modifier,
    itemContent: @Composable (item: Camera, modifier: Modifier) -> Unit,
) {
    val lazyGridState = rememberLazyGridState()
    LazyVerticalGrid(
        modifier = modifier,
        columns = GridCells.Fixed(3),
        state = lazyGridState,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        items(list) { id, modifier ->
            itemContent(id, modifier)
        }
    }
}

👆Step1: 要素の長押し検出と触覚フィードバック

要素の長押しを検知する処理を実装します。

実装のポイント

触覚フィードバックはperformHapticFeedback を呼び出すことで動作するため、以下のように実装することによってユーザーに長押し状態であることを知らせることができます。 Developers

...
    val haptic = LocalHapticFeedback.current
    LazyVerticalGrid(
        modifier = modifier.pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDragStart = { offset ->
                    // ドラッグ開始時に行いたい処理をここに実装する
                    haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 触覚フィードバック
                },
                onDrag = { change, dragAmount ->
                    // ドラッグ中に行いたい処理をここに実装する
                    change.consume() // ドラッグイベントの消費
                },
                onDragEnd = {
                    // ドラッグ終了時に行いたい処理をここに実装する
                },
                onDragCancel = {
                    // ドラッグがキャンセルされた時に行いたい処理をここに実装する
                }
            )
        },
...

↔️Step2: 要素をドラッグして移動する

Step1で記述したdetectDragGesturesAfterLongPressのコールバックにドラッグに関する処理を実装していきます。

実装のポイント

  • onDragStartでドラッグ対象のインデックスを取得
  • onDragでドラッグ量を加算して保持
  • ドラッグ対象要素に対してModifier.offset でドラッグ量を指定して移動
  • onDragEnd, onDragCancelでドラッグ量を初期化

(今回は簡単に実装するためにComposable内でインデックスやオフセット等の状態を管理していますが、Stateクラスの作成などViewとは分離して管理することをオススメします)

    val density = LocalDensity.current
    var draggingIndex by remember { mutableIntStateOf(0) }
    var dragOffset by remember { mutableStateOf(Offset.Zero) }
    LazyVerticalGrid(
        modifier = modifier.pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDragStart = { offset ->
                    // ドラッグ開始位置のインデックスを取得
                    draggingIndex = lazyGridState.layoutInfo.visibleItemsInfo.find { info ->
                        val rect = Rect(info.offset.toOffset(), info.size.toSize())
                        rect.contains(offset)
                    }?.index ?: -1
                    haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 触覚フィードバック
                },
                onDrag = { change, dragAmount ->
                    dragOffset += dragAmount
                    change.consume() // ドラッグイベントの消費
                },
                onDragEnd = {
                    dragOffset = Offset.Zero
                },
                onDragCancel = {
                    dragOffset = Offset.Zero
                }
            )
        },
...
    ) {
        itemsIndexed(
            items = list,
            key = { index, item -> item },
        ) { index: Int, item: String ->
            val dragging = draggingIndex == index
            val modifier = if (dragging) {
                Modifier
                    .offset(
                        x = with(density) { dragOffset.x.toDp() },
                        y = with(density) { dragOffset.y.toDp() },
                    )
                    .zIndex(1f) // ドラッグ中のアイテムを前面に表示
            } else {
                Modifier
            }
            itemContent(item, modifier)
        }
    }

🔄Step3: ドラッグによる要素の入れ替え処理

ドラッグ中アイテムの中心位置が他要素の領域に入ったタイミングで、リストの順番を入れ替える処理を実行します。

実装のポイント

  • 要素入れ替えコールバック(ex: onListChange)を定義し、並び替えの処理を実装する
  • ドラッグ中アイテムの中心位置を算出する
  • 中心位置が他要素の領域内に存在する場合、要素入れ替えコールバックを実行する

実際の入れ替えロジックは複雑になるため、ここでは詳細を割愛しますが、リスト変更を親に委ねるコールバック設計が重要です。詳細な実装はコチラを参照ください。

💫Step4: 入れ替わる際のアニメーションを実装

要素が入れ替わったことをユーザーにわかりやすく伝えるため、アニメーションを追加します。

このStepに関しては標準で提供されている機能を利用することで、手軽に実装することができます。

実装のポイント

        itemsIndexed(
            items = list,
            key = { index, item -> item }, // Compose側でアイテムを特定するためのキー
        ) { index: Int, item: String ->
            val dragging = draggableGridState.draggingIndex == index
            val offset = draggableGridState.dragOffset
            val modifier = if (dragging) {
                Modifier
                    .offset(
                        x = with(density) { offset.x.toDp() },
                        y = with(density) { offset.y.toDp() },
                    )
                    .zIndex(1f)
            } else {
                Modifier.animateItem() // 入れ替えられる側にアニメーションを設定
            }
            itemContent(item, modifier)
        }

⬇️Step5: リストの上下端到達時の自動スクロール

例えば「1番目から100番目に移動したい」といった大幅な移動を行いたい場合、現状は1回のドラッグにつき最大1画面分しか移動できないため、複数回のドラッグが必要になります。

この問題を解決するため、ドラッグによってリストの上下端まで持っていった場合はリスト自体のスクロールも行う機能を実装します。

実装のポイント

  • ドラッグ中の要素がリストの上下端からどれだけはみ出しているかを算出する
  • はみ出している場合は、はみ出した量に応じたY軸移動量を算出する
  • リストと要素の両方を同時にY軸移動量分だけ移動する(同期する)

この実装はパフォーマンスと競合回避の観点から複雑な部分であり、スクロールと要素移動を同時に行う同期処理に工夫が必要です。詳細な実装はコチラを参照ください。

🎉 まとめと今後の展望

本記事では、Jetpack Composeにおいて標準で提供されていない機能を、Modifier.pointerInput を活用してゼロから作り込む手法を解説しました。 タッチイベントの検出、オフセットによる位置調整、Modifier.animateItem()によるアニメーション、そして自動スクロールを組み合わせることで、従来のAndroid ViewのRecyclerView+ItemTouchHelperに匹敵する、高度なUXを実現することができました。 この実装には手探りの部分が多く、リファクタリングやパフォーマンス改善の余地は十分にあると考えていますが、本記事が皆様のCompose実装の手助けとなれば幸いです。

👀将来的な見通し

執筆時点では自前での実装が必要でしたが、ロードマップにはドラッグ&ドロップに関する記載があるため、将来的には標準機能としてサポートされる可能性が高いです。

出典:Jetpack Compose のロードマップ https://developer.android.com/jetpack/androidx/compose-roadmap?hl=ja (2025年12月3日アクセス)

それまでは今回解説したような自前の実装を行うか、あるいは、従来のAndroid ViewのItemTouchHelperComposeViewでホストして利用するという選択肢も一つの回避策となります。状況に応じて、最適なアプローチを選択してください。

💻 実装コード

執筆にあたって実装したコード全体は、以下のGitHubリポジトリで公開しています。是非参考にしてください。

https://github.com/ruiqma/DraggableGridSample

モバイルチームは開発する仲間を募集しています!

open.talentio.com

open.talentio.com

© Safie Inc.