目次

目次

【Jetpack Compose】LazyColumnのアイテムを並び替えの仕組み(前編)

アバター画像
寺島広
アバター画像
寺島広
最終更新日2024/10/25 投稿日2024/10/25

はじめに

こんにちは。NX開発推進部 Androidアプリ開発グループ の寺島です。

今回はAndroidアプリのUIの実装について記事を書きます。 Androidには現在2種類のUI構築方法があります。

  • XMLによるレイアウト
  • Jetpack Compose

Googleでは、Jetpack Composeによる実装を推奨しています。

Jetpack Compose は、ネイティブ UI を構築するための Android 推奨の最新ツールキットです。

公式

AndroidのUI構築は、従来のXMLによるアプローチから、Jetpack Composeによる宣言的アプローチへと進化しました。XMLを使用した方法では、レイアウトを視覚的に構築しやすい一方で、動的なUI変更やカスタムビューの作成においては冗長なコードが必要でした。これに対し、Jetpack ComposeはUIをコードで直接記述し、状態に応じて自動的に更新されるため、柔軟で保守性の高いUI開発が可能です。この変遷により、開発者はよりシンプルで効率的な方法でモダンなUIを構築できるようになっています。

また、Googleは、Jetpack Composeを使った開発を推奨しています。したがって、Androidアプリの開発では、XMLでのレイアウト構築からJetpack Composeでの構築に移行することがしばしばあります。

Jetpack Composeの一番最初の安定版ライブラリが出てから、3年が経ちました。 かなり作れるUIの幅も広がってきたのですが、まだライブラリとしてサポートされていない機能もいくつか存在します。

  • リスト上でのドラッグ & ドロップ
    • アイテムの並び替えなどに使用する機能
  • CollapsingToolbarLayout
    • 公式
    • 簡単に説明するとスクロールによってTool Bar(Action Bar)が変形するレイアウトのこと
    • ComposeではTopAppBarとしている

つい最近追加されて可能になったものあるくらいです。

まだ対応されていないが、どうしても実装しないといけないUIに関しては自前で実装する必要が出てきます。

今回は自前で実装する必要がある リスト上でのドラッグ & ドロップについて書いていきます。

実現したい機能について

まず、今回Composeで実現したいUIについて細かく説明していきます。 XMLではRecyclerViewとItemTouchHelperを合わせて使うことで、リスト上での並び替えの実装が可能でした。

以下が実際にRecyclerViewとXMLで実装した場合の動きです。

前述したように、Composeでの並び替えはまだ正式なライブラリが出てません。したがって、自前で実装していく必要があります。(非公式のライブラリは、探せば割と見つけることができます。)

達成したいポイント

Composeにする際に達成しておきたい点は以下になります。

  • ドラッグ&ドロップで楽曲の並び替えができる
    • ただし、楽曲以外の要素が並び替わることがないようにする
  • 並び替えようとしているアイテムが画面の端に来たときはスクロールをする

上記を達成できれば、並び替えに関してはXMLからComposeへの移行ができたことになります。


実装内容

上記で話した達成したいポイントがクリアできるようなアプリを実装します。

実装する上でやることは以下です。

  • 長押しによるドラッグ&ドロップ
  • アイテムの並び替え
  • オーバースクロール(ドラッグしたアイテムが画面端に来た時にスクロールさせる)

長押しによるドラッグ&ドロップ

長押しによって、アイテムをドラッグ&ドロップできるようにするために、 Modifierの修飾子で設定する必要があります。

pointerInputにはPointerInputScopeを渡すことで特定のジェスチャーを検出できます。今回は長押しの際のジャスチャーを検出したいため、 detectDragGesturesAfterLongPressを使用します。

実際にコードは以下の様になります。

val lazyListState = rememberLazyListState()
var draggingItemIndex: Int? by remember {
    mutableStateOf(null)
}
var draggingItem: LazyListItemInfo? by remember {
    mutableStateOf(null)
}
・・・
LazyColumn(
    modifier = Modifier
        .pointerInput(
            key1 = lazyListState
        ) {
            detectDragGesturesAfterLongPress(
                onDragStart = { offset ->
                    lazyListState.layoutInfo.visibleItemsInfo
                        .firstOrNull { item ->
                            // ドラッグ位置からドラッグされるアイテムを探す
                            offset.y.toInt() in item.offset..(item.offset + item.size)
                        }?.also { draggedItem ->
                            // dragされているアイテムそのものを保存しておく
                            draggingItem = draggedItem
                            // dragされているアイテムのインデックスを保存しておく
                            draggingItemIndex = draggedItem.index
                        }
                },
                ・・・
            )
        }
        ・・・
)

visibleItemsInfoは、LazyColumnで表示しているアイテムの情報を取得できます。LazyColumn内で itemsスコープ内で指定したアイテムは、すべてこのリストの中に情報が格納される様になっています。

今回はリスト内に含まれる全てのアイテムの位置を、1つずつ確認していきます。タップした位置がリスト内のどのアイテムの位置になるかを検出することで、ドラッグするアイテムを検知できます。

図で表すと下記のような感じです。

上記コードでは、赤いタップした箇所とそれぞれのアイテムの枠内に含まれるかをチェックして、タップした箇所があるアイテムの枠内に含まれた場合は、そのアイテムをドラッグするアイテムとする処理になります。例えば、2つ目のアイテムの枠内に含まれる場合は、以下の式で含まれているか否かを判定します。2つ目のアイテムの上端を top 、下端を bottom とします。さらにタップした箇所を y とします。

bottom ≧ y ≧ top

y が上記の式を満たすとき、2つ目のアイテムがドラッグされます。

アイテムの並び替え

次にアイテムの並び替えをしていきます。並び替えはドラッグされているアイテムとそれ以外のアイテムの位置を比較して入れ替えていきます。下記が実際のコードです。

・・・
var delta: Float by remember {
    mutableStateOf(0f)
}
val onMove = { fromIndex: Int, toIndex: Int ->
    rememberList =
        rememberList.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
}
・・・
LazyColumn(
    modifier = Modifier
        .pointerInput(
            key1 = lazyListState
        ) {
            detectDragGesturesAfterLongPress(
                ・・・
                onDrag = { change, dragAmount ->
                            change.consume()
                    // ドラッグによる変化量を取得
                    delta += dragAmount.y
                    // 現在ドラッグされているアイテムのインデックス
                    val currentDraggingItemIndex =
                        draggingItemIndex ?: return@detectDragGesturesAfterLongPress
                    // 現在ドラッグされているアイテム
                    val currentDraggingItem =
                        draggingItem ?: return@detectDragGesturesAfterLongPress
                    // ドラッグしているアイテムの上端の位置
                    val startOffset = currentDraggingItem.offset + delta
                    // ドラッグしているアイテムの下端の位置
                    val endOffset =
                        currentDraggingItem.offset + currentDraggingItem.size + delta
                    // ドラッグしているアイテムの中間の位置
                    val middleOffset = startOffset + (endOffset - startOffset) / 2
                    // リストのアイテムを1つずつみて、
                    // ドラッグ中のアイテムの中間の位置が別のアイテムの上端から下端に含まれた場合
                    // そのアイテムを入れ替えのターゲットとする。
                    val targetItem =
                        lazyListState.layoutInfo.visibleItemsInfo.find { item ->
                            middleOffset.toInt() in item.offset..item.offset + item.size &&
                                    currentDraggingItem.index != item.index
                        }
                    // アイテムを入れ替えて、入れ替えたあとももともと動かしてたアイテムがドラッグされるようにする
                    if (targetItem != null) {
                        val targetIndex = targetItem.index
                        // アイテムを入れ替え
                        onMove(currentDraggingItemIndex, targetIndex)
                        // 入れ替え後ももともとドラッグしていたアイテムがドラッグされるようにする
                        draggingItemIndex = targetIndex
                        draggingItem = targetItem
                        // 入れ替えたときに動いた分をdeltaに反映
                        delta += currentDraggingItem.offset - targetItem.offset
                    }
                },
                ・・・
            )
        }
        ・・・
)

コードについて細かくみていきます。 コード中のコメントにも細かく説明していますが、個人的にわかりづらかった箇所を図解します。 まず、 middleOffsetについてです。 下の図では橙色の線がmiddleOffsetの位置を表しています。

ドラッグしたアイテムのmiddleOffsetの位置によって、アイテムを入れ替えるか判断しています。2つ目のアイテムのy座標を yとして、1つ目のアイテムの下端のy座標を bottom 、3つ目のアイテムの上端のy座標を top とします。

例えば、ドラッグしたアイテムのmiddleOffsetが、別のアイテムの下端に達すると以下のようになり、

判定式は以下のようになります。

bottom ≧ y

例えば、ドラッグしたアイテムのmiddleOffsetが、別のアイテムの上端に達すると以下のようになります。

判定式は以下のようになります。

y ≧ top

このように middleOffsetが各アイテムの橙色の領域まで到達すると、入れ替える2つのアイテムとして検知し、検知したタイミングでリストの中身を入れ替える処理を行います。

これによって、リストの入れ替えを行うことができます。

オーバースクロール

次にオーバースクロールです。こちらはドラッグしたアイテムがリストの上端・下端に来た時にスクロールをさせる処理を実装します。

以下がコードになります。

・・・
val scrollChannel = Channel<Float>()
LaunchedEffect(lazyListState) {
    while (true) {
        val diff = scrollChannel.receive()
        lazyListState.scrollBy(diff)
    }
}
・・・
LazyColumn(
    modifier = Modifier
        .pointerInput(
            key1 = lazyListState
        ) {
            detectDragGesturesAfterLongPress(
                ・・・
                    if (targetItem != null) {
                        ・・・
                    } else {
                // ドラッグしているアイテムの上端の位置から画面上端の位置を引いた値
                // 負の値になっている場合は上にスクロールさせたい
                val startOffsetToTop = startOffset - lazyListState.layoutInfo.viewportStartOffset
                        // ドラッグしているアイテムの下端の位置から画面下端の位置を引いた値
                // 正の値になっている場合は上にスクロールさせたい
                        val endOffsetToBottom = endOffset - lazyListState.layoutInfo.viewportEndOffset
                        // スクロールさせるかを判定
                        val scroll =
                            when {
                                startOffsetToTop < 0 -> startOffsetToTop.coerceAtMost(0f)
                                endOffsetToBottom > 0 -> endOffsetToBottom.coerceAtLeast(0f)
                                else -> 0f
                            }
                        // 上下どちらにスクロールするかを判定する
                        val canScrollDown = currentDraggingItemIndex != rememberList.size - 1 && endOffsetToBottom > 0
                        val canScrollUp = currentDraggingItemIndex != 0 && startOffsetToTop < 0
                        if (scroll != 0f && (canScrollUp || canScrollDown)) {
                    // 画面から飛び出た分だけスクロールさせる
                            scrollChannel.trySend(scroll)
                        }
                    }
                },
                ・・・
            )
        }
        ・・・
)
・・・

ドラッグしているアイテムがリストの上端・下端に来た時に、直接リストをスクロールさせる処理を呼び出しているのが上記のコードになります。

例えば、以下の図のような状態があったとします。赤線を startOffsetToTop とし、青線を endOffsetToBottom とします。

この時、ドラッグされているのは、真ん中のアイテムでこのアイテムの middleOffsetstartOffsetToTopendOffsetToBottomの位置に来たかを検知し、scrollChannel.trySend(scroll)の処理を行っています。

この様にして、リストのアイテムが上端・下端に来た時はリストをスクロールさせています。

実際にスクロールさせる幅は、どれだけ上端・下端を越えたかを計算し決めています。例えば、ドラッグしているアイテムのy座標を y 、リストの上端のy座標を listTop 、リストの下端のy座標を listBottom とします。このとき、判定式は以下になります。

上端までアイテムがドラッグされた場合

y ≦ listTop

下端までアイテムがドラッグされた場合

listBottom ≦ y

これで必要な実装は完了しました。


完成したサンプルの動作

実際に作ったサンプルの動作は以下のようになります。

パンのアイテムが並び替えられるように作っています。また、ドラッグ中のアイテムがリストの端まで達するとスクロールするように実装できています。ここではリスト以外にもComposeで要素を置いているのですが、リスト以外のComposableは並び替えに反応しないようになっています。

今回は並び替えの部分は実装できたのですが、並び替えている最中のアイテムの動きがアニメーションで表示されないため、動かしている事が分かりづらいです。したがって、改めて並び替えのアニメーションについて記事を書ければと思います。


まとめ

近年、AndroidアプリのUI実装の課題になっている XMLのレイアウトからComposeへの移行 についての一例を紹介しました。

まだまだComposeのライブラリそのもので実装できない事も多いですが、段々とXMLで出来ることがComposeでも出来るようになってきました。自分の携わるプロジェクトでも全てのUIをComposeで実装できればなと考えてます。

ここまで読んでいただきありがとうございます。

私が実装したものをGitHubにあげています。LazyColumnで並び替えを実装しようとしている方の参考になれば幸いです。

GitHub


参考

アバター画像

寺島広

レコチョクの寺島です。
Android アプリ開発に携わっています。
アニメ、ゲームなどが好きです。

目次