なんだか最近 RuboCop のキャッシュファイルが git diff に入ってくる方へ

こんにちは、レシピ事業部バックエンド基盤部の石川です。これは RuboCop のバージョンを上げましょうという記事です。

なんだか最近 RuboCop を使っていたら .rubocop-29343e612b03ba2227a3c3390a755e8d.yml のような名前のファイルが .gitignore を貫通してきませんか?

これは他リポジトリなどのリモートにある設定ファイルを inherit_from で参照したときに作られるキャッシュファイルです。ちょっと前までは .rubocop-https---raw-githubusercontent-com-cookpad-styleguide-master--rubocop-yml のような名前だったのが、2025 年末あたりに .rubocop-remote-23b9c67aff31e0f9d6c4a89d5eb660cb.yml のような名前になり *1、そのあと現在の名前になりました *2。現在の名前は $original_name-$hash.yml という規則になっています。

そのような感じでキャッシュファイルの名前が変わり、そしてよくある .gitignore では .rubocop-https?--* のみが ignore されていることが多く、新しいファイル名がこのルールに引っかからなくなったため git diff に現れるようになったという次第です。

さて、実は先日リリースされた RuboCop v1.84.2 からは、デフォルトでこれらのキャッシュファイルがプロジェクトの一番上のディレクトリではなく ~/.cache/rubocop_cache/ のような共通のディレクトリへ保存されるようになりました *3

したがって、お使いの RuboCop のバージョンを上げていただければこれらのキャッシュファイルは git diff を邪魔しなくなります。プロジェクトのディレクトリの外に保存されるためです。お試しください。

Include/Exclude に関する注意点

ところで、この変更に伴い、inherit_from で参照される設定ファイルの側で IncludeExclude を設定している方には注意点があります。IncludeExclude では相対パスを使って RuboCop の対象となるファイルを制御できますが、実はこの相対パスの起点が設定ファイルの名前によって変わります。

https://docs.rubocop.org/rubocop/configuration.html#path-relativity

In .rubocop.yml and any other configuration file beginning with .rubocop, files, and directories are specified relative to the directory where the configuration file is. In configuration files that don’t begin with .rubocop, e.g. our_company_defaults.yml, paths are relative to the directory where rubocop is run.

上記のドキュメントに書かれているように、名前が .rubocop から始まる設定ファイルではその設定ファイルの場所からの相対パスになり、それ以外の場合は rubocop コマンドが実行された場所からの相対パスになります。

今のところ、これは inherit_from で参照されている先の設定ファイルの名前についても同様に判断される挙動になっています。したがって RuboCop のバージョンを上げると意図しない挙動になる可能性があります。

RuboCop v1.84 では、元の設定ファイル名の先頭がキャッシュファイル名の先頭に残る上、キャッシュファイルの保存先がプロジェクトの一番上ではなくなります。このためリモートにある .rubocop.ymlinherit_from で参照している場合、IncludeExclude の相対パスの起点がプロジェクトの一番上でなくなってしまいます。

実際にファイル名によって挙動が変わる様子を以下に示します。ふたつ実行していて、最初はピリオドありの .rubocop.yml、次はピリオドなしの rubocop.yml を使っており、両方とも中身は Exclude です。両者について RuboCop v1.84.2 を動かすと、前者は spec/app_spec.rb が除外されず検査対象に残ってしまっています。なお RuboCop v1.84.1 のデフォルト挙動では前者でも後者と同じく spec/app_spec.rb が対象になっていませんでした。

% rubocop -v
1.84.2
% tree -a .
.
├── .rubocop.yml
├── app.rb
└── spec
    └── app_spec.rb

2 directories, 3 files
% cat .rubocop.yml
inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
# inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml

AllCops:
  DisabledByDefault: true
% curl -fsSL https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
AllCops:
  Exclude:
    - 'spec/**/*'
% rubocop --list-target-files
app.rb
spec/app_spec.rb
% nano .rubocop.yml
% cat .rubocop.yml
# inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml

AllCops:
  DisabledByDefault: true
% rubocop --list-target-files
app.rb

これを避けるため、inherit_from している先のファイル名が適切かチェックしておくのが良いでしょう。弊社では mv .rubocop.yml rubocop.yml としたあと、互換性のために .rubocop.yml では inherit_from: rubocop.yml で参照しておいて徐々に移行していくという手順を試しています。

以上、最近の RuboCop についての情報共有でした。まずはぜひバージョンを上げてみてください。

cookpad.careers

Compose で Drag and Drop を用いてリストの並び替えを実現する

はじめに

こんにちは。レシピ事業部でアルバイト中の松本 (@matsumo0922) です。クックパッドでは、作るレシピを日付ごとに管理できる、プラン機能をつい先日リリースしました。この機能は Full-Compose で作成されており、日付間のレシピの移動/並び替えに Drag and Drop を採用しています。Drag and Drop は本来リストの並び替えに用いるものでは無いですが、今だに Compose ではリストの並び替え API が充実していないのに加え、視覚効果が貧弱なものが多いのが現状です。そこで Drag and Drop を応用的に並び替え UI に用いることで、直感的で視覚的にもわかりやすい UI/UX を実現することができました。今回は Compose で Drag and Drop を用いて、リストの並び替えを実装する方法と知見をご紹介します。

プラン機能での Drag and Drop を用いた並び替え

Drag and Drop の基本

Drag and Drop(以下 DnD)は、ユーザーが要素をドラッグし、別の位置にドロップすることで、並び替えや移動などの操作を直感的に行える UI パターンです。広義では View 間や画面間、アプリ間でのデータのやり取りが可能な機能のことを指します。そのため一般的なリストパターンである「選択と移動」とは異なる機能であることに注意が必要です。プラン機能では、ある日付に登録されたレシピ(アイテム)を別の日付(セクション)へ視覚的に移動させる必要があったため、通常の並び替えではなく DnD を採用することにしました。

Compose での DnD は二つの修飾子で実装することができます。

  • Modifier.dragAndDropSource
  • Modifier.dragAndDropTarget

それぞれ Drag の起点となる Composable と Drop 先の Composable を指します。今回は簡単のために二つの Composable 間でテキストデータをやり取りする実装を考えてみます。

なお、本記事の最終目的は DnD を用いてリストの並び替え UI を実装することなので、基本的な DnD の仕組みや実装を理解されている方は「並び替えへの応用」まで読み飛ばしていただいて構いません。

dragAndDropSource

データの送信元となる Composable につける修飾子です。送信したいデータはテキストや画像、バイナリなど複数ニーズがあると思いますが、すべて ClipData クラスでラップして送信します。送信元がわかるように事前に示し合わせた label を付けてインスタンスを生成し、DragAndDropTransferData を返してあげることで DnD がスタートします。label はユーザーへの Description としても用いられることに注意してください。今回は “Hello!” というテキストデータを送信してみます。

private const val LABEL = "DnD sample data for Cookpad."

Box(
    modifier = Modifier
        .size(128.dp)
        .background(Color.Red)
        .dragAndDropSource { _ ->
            DragAndDropTransferData(ClipData.newPlainText(LABEL,"Hello!"))
        }
)

上記のコードでドラッグの検知もすべて行ってくれます。ドラッグのタイミングを自分でコントロールしたい場合は、detectDrag... などの Modifier で自分で Drag を検知し、startTransfer を呼び出してあげることで DnD を開始することができます。以下の例は、長押し後のドラッグのみを検知する例です。この Composable 自体が Clickable である場合などに活躍します。

Box(
    modifier = Modifier
        .size(128.dp)
        .background(Color.Red)
        .dragAndDropSource(
            block = {
                // clickable と両立させるために、長押し後のドラッグのみ検知する
                detectDragGesturesAfterLongPress(
                    onDrag = { _, _ ->
                        /* no-op */
                    },
                    onDragStart = { _ ->
                        val clipData = ClipData.newPlainText(MEAL_PLAN_DAD_ITEM_LABEL, id)
                        val data = DragAndDropTransferData(clipData)
                        
                        startTransfer(data)
                    },
                )
            },
        )
)

DnD を開始すると、デフォルトでは当該の Composable を半透明にしたものが視覚効果として提供されます。これを変更したい場合は、drawDragDecoration パラメータのラムダ内で DrawScope が提供されているので、これを用いて任意の視覚効果に変更することができます。

dragAndDropTarget

データを受信する Composable につける修飾子です。受け取り状態を Boolean で返す shouldStartDragAndDrop と、DragAndDropTarget という DnD の状態を受け取るコールバックをパラメータに指定します。今回は受け取ったデータをそのまま表示するので、onDrop() 内で先ほど示し合わせた Label かどうかを確認した上で、receiveItem にセットしています。返り値はデータを消費した場合は true、消費しなかった場合は false を返します。

var receiveItem by remember { mutableStateOf("") }

Box(
    modifier = Modifier
        .size(128.dp)
        .background(Color.LightGray)
        .dragAndDropTarget(
            shouldStartDragAndDrop = { true },
            target = object : DragAndDropTarget {
                override fun onDrop(event: DragAndDropEvent): Boolean {
                    val clip = event.toAndroidDragEvent().clipData
                    val item = clip.getItemAt(0).text.toString()

            if (clip.description.label != LABEL) return false

                    receiveItem = item
                    return true
                }
            }
        ),
    contentAlignment = Alignment.Center
) {
    Text(receiveItem)
}

DragAndDropTarget では DnD の開始や終了、Drag が受け取り可能範囲に入ったか出ていったかなどの情報も取得することができます。詳しくはドキュメントをご覧ください。

  • onStarted: ドラッグが開始された時に呼ばれる。このターゲットがデータを受け入れ可能かを返す。
  • onEntered: ドラッグ領域に入った時。
  • onMoved: 領域内で移動中。
  • onExited: 領域から出た時。
  • onDrop: ドロップされた時。ここでデータを取得する。
  • onEnded: ドラッグ操作が終了した時。

https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).dragAndDropTarget(kotlin.Function1,androidx.compose.ui.draganddrop.DragAndDropTarget

基本コード全体

前述のコードをまとめて動かしてみます。赤い Box が source、グレーの Box が target です。

var sendItem by remember { mutableStateOf("Hello!") }
var receiveItem by remember { mutableStateOf("") }

Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(
        space = 128.dp,
        alignment = Alignment.CenterVertically,
    )
) {
    // 送信側
    Box(
        modifier = Modifier
            .size(128.dp)
            .background(Color.Red)
            .dragAndDropSource { _ ->
                DragAndDropTransferData(ClipData.newPlainText(LABEL, sendItem))
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(sendItem)
    }

    // 受信側
    Box(
        modifier = Modifier
            .size(128.dp)
            .background(Color.LightGray)
            .dragAndDropTarget(
                shouldStartDragAndDrop = { true },
                target = object : DragAndDropTarget {
                    override fun onDrop(event: DragAndDropEvent): Boolean {
                        val clip = event.toAndroidDragEvent().clipData
                        val item = clip.getItemAt(0).text.toString()

                        receiveItem = item
                        return true
                    }
                }
            ),
        contentAlignment = Alignment.Center,
    ) {
        Text(receiveItem)
    }
}

赤色の Box から灰色 Box への DnD

リッチな視覚効果

上記のコードを改良することで視覚的にセクション間の移動を実現することができます。しかし、視覚効果は最低限でユーザーにとって分かりやすい UI になっているとは言い切れません。もう少しリッチな視覚効果が欲しいところです。プラン機能では日付毎にセクションが独立しているので、ドロップされる日付を拡大 & ハイライトすることで、より分かりやすい UI を実現できました。今回は DropTarget に拡大して枠線をつけてみます。

前述の通り、DragAndDropTarget では DnD の開始や終了、Drag が受け取り可能範囲に入ったか出ていったかを取得することができるので、これを利用します。isFocused という変数で Drop 可能時に受け取り側の Composable を大きく、そして枠線を表示するようにしてみましょう。Modifier の適用順序に注意してください。

// Drop 可能領域に入っているか
var isFocused by remember { mutableStateOf(false) }

val focusedScale by animateFloatAsState(
    targetValue = if (isFocused) 1.2f else 1f,
    label = "focusedScale",
)

val focusedColor by animateColorAsState(
    targetValue = if (isFocused) Color.Red else Color.Transparent,
    label = "focusedColor",
)

val dragAndDropTarget = remember {
    object : DragAndDropTarget {
        // Drop した時
        override fun onDrop(event: DragAndDropEvent): Boolean {
            val clip = event.toAndroidDragEvent().clipData
            val item = clip.getItemAt(0).text.toString()
            receiveItem = item
            isFocused = false
            return true
        }

        // Drop 可能領域に入った時
        override fun onEntered(event: DragAndDropEvent) {
            isFocused = true
        }

        // Drop 可能領域から出て行った時
        override fun onExited(event: DragAndDropEvent) {
            isFocused = false
        }

        // DnD が終了した時
        override fun onEnded(event: DragAndDropEvent) {
            isFocused = false
        }
    }
}

// 受信側
Box(
    modifier = Modifier
        .size(128.dp)
        .graphicsLayer(
            scaleX = focusedScale,
            scaleY = focusedScale,
        )
        .border(
            width = 2.dp,
            color = focusedColor,
        )
        .background(Color.LightGray)
        .dragAndDropTarget(
            shouldStartDragAndDrop = { true },
            target = dragAndDropTarget,
        ),
    contentAlignment = Alignment.Center,
) {
    Text(receiveItem)
}

Drop 可能時に視覚効果を追加する

このように、単にデータを受け取るだけでなく、ちょっとした視覚的なフィードバックを加えることで、UX を向上させることができます。スマートフォンなどのタッチデバイスでは、自分の指で画面の一部が隠れてしまいがちです。そのため、ドロップ先が「受け入れ可能です!」とリアクションすることでアフォーダンスの向上や操作ミスの防止、ひいては操作への納得感に繋げることができるはずです。

特にプラン機能のような、画面内に複数のドロップターゲット(日付)が存在するケースでは、このような細やかなインタラクションがアプリの使い心地を大きく左右するかもしれません。

並び替えへの応用

上記のコードを改良することで視覚的にセクション間の移動を実現することができます。しかし、私たちが開発していたプラン機能では日付内(セクション内)でのアイテムの並び替えも実現する必要がありました。ここからは DnD を活用したリストの並び替えについて解説していきます。

サンプルデータとしてプラン機能と同じように、セクションの中にアイテムを保持する構造を定義します。

@Stable
data class SectionData(
    val id: String,
    val label: String,
    val items: List<ItemData>,
)

@Stable
data class ItemData(
    val id: String,
    val label: String,
)

val sections = remember {
    mutableStateListOf(
        SectionData(
            id = "section-1",
            label = "Section 1",
            items = listOf(
                ItemData("A", "Item A"),
                ItemData("B", "Item B"),
                ItemData("C", "Item C"),
            )
        ),
        SectionData(
            id = "section-2",
            label = "Section 2",
            items = listOf(
                ItemData("D", "Item D"),
                ItemData("E", "Item E"),
                ItemData("F", "Item F"),
            )
        ),
        SectionData(
            id = "section-3",
            label = "Section 3",
            items = listOf(
                ItemData("G", "Item G"),
                ItemData("H", "Item H"),
                ItemData("I", "Item I"),
            )
        )
    )
}

このデータを表示してみます。Section と Item という Composable を用意しました。Section 全域を DropTarget にして、Item を DragSource にします。前述した DnD の視覚効果も合わせて実装してみましょう。

@Composable
private fun Section(
    sectionData: SectionData,
    modifier: Modifier = Modifier,
) {
    var isFocused by remember { mutableStateOf(false) }

    val focusedScale by animateFloatAsState(
        targetValue = if (isFocused) 1.05f else 1f,
        label = "focusedScale",
    )

    val focusedColor by animateColorAsState(
        targetValue = if (isFocused) Color.Red else Color.Transparent,
        label = "focusedColor",
    )

    val dragAndDropTarget = remember {
        object : DragAndDropTarget {
            override fun onDrop(event: DragAndDropEvent): Boolean {
                isFocused = false
                return true
            }

            override fun onEntered(event: DragAndDropEvent) {
                isFocused = true
            }

            override fun onExited(event: DragAndDropEvent) {
                isFocused = false
            }

            override fun onEnded(event: DragAndDropEvent) {
                isFocused = false
            }
        }
    }

    Column(
        modifier = modifier
            .graphicsLayer(
                scaleX = focusedScale,
                scaleY = focusedScale,
            )
            .border(
                width = 2.dp,
                color = focusedColor,
            )
            .dragAndDropTarget(
                shouldStartDragAndDrop = { true },
                target = dragAndDropTarget,
            ),
    ) {
        Text(
            text = sectionData.label,
            style = MiseTheme.typography.titleSmall,
        )

        sectionData.items.forEach { item ->
            Item(
                modifier = Modifier
                    .fillMaxWidth()
                    .dragAndDropSource { _ ->
                        DragAndDropTransferData(ClipData.newPlainText(LABEL, item.id))
                    },
                itemData = item,
            )
        }
    }
}

@Composable
private fun Item(
    itemData: ItemData,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier.background(Color.LightGray, RoundedCornerShape(8.dp)),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = itemData.label,
            style = MiseTheme.typography.bodyMedium,
        )
    }
}

プラン機能と構造的に同じ UI

プラン機能のような UI ができました。この実装をベースに並び替えのロジックを追加していきます。
最初にも述べた通り、DnD は本来リストの並び替えなどに用いるものではないため、Item の Index を取得できる便利メソッドなどは存在しません。提供されるのは DragAndDropTarget から得られるドラッグ中及びドロップされた座標のみです。この座標からアイテムを並び替えるべき Index を計算によって求めることになります。

DragAndDropTarget から取得できる座標は画面全体から見た座標なので、セクション自体のY座標とHeaderの高さ、アイテムの高さから Index を求めることができます。DropTarget からみた相対座標ではないので注意してください。ComposableのY座標や高さは Modifier.onGloballyPositionedModifier.onSizeChanged から取得することができます。

fun computeSlotIndex(yLocalInParent: Float): Int {
    // DropTarget の Items Column の相対座標に計算し直す
    val yLocal = yLocalInParent - currentHeaderHeight - currentParentTop
    if (yLocal < 0f) return 0

    for (index in currentItems.indices) {
        val itemId = currentItems[index].id

        // onGloballyPositioned で取得した Bounds
        val bounds = currentRowBounds[itemId] ?: continue

        // この Item の相対座標
        val rowTop = bounds - currentHeaderHeight - currentParentTop
        val rowBottom = rowTop + currentRowHeight

        // この Item 内で上部分にあればこの Item の前、
        // 下部分にあればこの Item の後ろの Index を返す
        if (yLocal in rowTop..rowBottom) {
            return if ((rowBottom - rowTop) / 2 < yLocal) index + 1 else index
        }
    }

    return if (yLocal > currentItems.size * currentRowHeight) {
        currentItems.size
    } else {
        -1
    }
}

DragAndDropTarget は remember されているため、外側の変数の変化を受け取ることができません。そこで rememberUpdatedState を用いて最新の値を受け取ることができるようにします。remember に key を設定することでもこの問題は解決できますが、値の更新があるたびに Callback を作り直してしまい、挙動が不安定になってしまうため rememberUpdatedState を使用します。

@Composable
private fun Section(
    sectionData: SectionData,
    onItemDropped: (itemId: String, index: Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    var isFocused by remember { mutableStateOf(false) }
    var hoveredSlotIndex by remember { mutableIntStateOf(-1) }
    var parentTopInRoot by remember { mutableFloatStateOf(0f) }
    val rowBoundsInRoot = remember { mutableStateMapOf<Any, Float>() }
    var headerHeight by remember { mutableFloatStateOf(0f) }
    var rowHeight by remember { mutableFloatStateOf(0f) }

    // Callback の中でも最新の値が受け取れるように UpdatedState 化
    val currentItems by rememberUpdatedState(sectionData.items)
    val currentHeaderHeight by rememberUpdatedState(headerHeight)
    val currentParentTop by rememberUpdatedState(parentTopInRoot)
    val currentRowHeight by rememberUpdatedState(rowHeight)
    val currentRowBounds by rememberUpdatedState(rowBoundsInRoot)
    val currentOnItemDropped by rememberUpdatedState(onItemDropped)

    // 中略

    val dragAndDropTarget = remember {
        object : DragAndDropTarget {
            override fun onDrop(event: DragAndDropEvent): Boolean {
                val clip = event.toAndroidDragEvent().clipData
                val itemId = clip.getItemAt(0).text.toString()
                currentOnItemDropped.invoke(itemId, hoveredSlotIndex)
                isFocused = false
                return true
            }

            override fun onEntered(event: DragAndDropEvent) {
                isFocused = true
            }

            override fun onExited(event: DragAndDropEvent) {
                isFocused = false
                hoveredSlotIndex = -1
            }

            override fun onEnded(event: DragAndDropEvent) {
                isFocused = false
                hoveredSlotIndex = -1
            }

            override fun onMoved(event: DragAndDropEvent) {
                val yLocal = event.toAndroidDragEvent().y
                hoveredSlotIndex = computeSlotIndex(yLocal)
            }
        }
    }

    // 中略
}

Section Composable の引数に onItemDropped というラムダを渡すようにしました。この内部で並び替えのロジックを記述します。以下ではローカルで並び替えるために複雑な処理を行っていますが、実際には API などに並び替え情報を送ることが多いかもしれません。

// 移動元のセクションとアイテムを探す
val sourceSectionIndex = sections.indexOfFirst { sec ->
    sec.items.any { it.id == itemId }
}
if (sourceSectionIndex == -1) return@Section

val sourceSection = sections[sourceSectionIndex]
val movedItem = sourceSection.items.find { it.id == itemId } ?: return@Section

// 移動先のセクションのインデックスを探す
val targetSectionIndex = sections.indexOfFirst { it.id == section.id }
if (targetSectionIndex == -1) return@Section

// データの更新処理
// 同じセクション内での移動か、別のセクションへの移動かで分岐
if (sourceSectionIndex == targetSectionIndex) {
    // 移動元から削除
    val currentItems = sourceSection.items.toMutableList()
    currentItems.remove(movedItem)

    // 移動先に挿入
    val safeIndex = index.coerceIn(0, currentItems.size)
    currentItems.add(safeIndex, movedItem)

    sections[sourceSectionIndex] = sourceSection.copy(items = currentItems)
} else {
    // 移動元から削除
    val newSourceItems = sourceSection.items.toMutableList()
    newSourceItems.remove(movedItem)
    sections[sourceSectionIndex] = sourceSection.copy(items = newSourceItems)

    // 移動先に挿入
    val targetSection = sections[targetSectionIndex]
    val newTargetItems = targetSection.items.toMutableList()
    val safeIndex = index.coerceIn(0, newTargetItems.size)
    newTargetItems.add(safeIndex, movedItem)
    sections[targetSectionIndex] = targetSection.copy(items = newTargetItems)
}

DnD を用いた並び替え

これでアイテムを並び替えることができるようになりました。今回の実装には含まれていませんが、プラン機能ではこれらの実装に加え、ドロップ予定の Index にオレンジ色の破線を表示し、どのアイテムの間に配置されるのか分かりやすくしています。hoveredSlotIndex の箇所に線を表示するだけなので、小さな実装コストで UX を改善することができるかもしれません。

並び替えの機能自体はこれで完成ですが、リストの並び替えUIにおいてもう一つ大事な機能を実装する必要があります。ドラッグ位置に応じた、オートスクロールです。RecyclerView などで提供されている並び替え API を利用しているとどうしても忘れがちですが、リストの並び替えを実現する以上画面外へ並び替えるユースケースも十分に想定されます。こちらも便利メソッドなどは提供されていませんので、自分たちで実装する必要があります。

前述の通り、ドラッグ中の座標は DragAndDropTarget より取得できるので、位置に応じて連続でスクロール API を呼べば良さそうです。Section 内に以下の処理を書くことで、ドラッグが自身の上にある場合にスクロール処理を担当させるようにします。そのため、Section と Section の間に padding などがあった場合はスクロールが途切れてしまうので注意してください。

LaunchedEffect(isFocused, currentDragY) {
    if (!isFocused || currentDragY == 0f) return@LaunchedEffect
    val scrollThresholdPx = with(density) { 120.dp.toPx() }
    val scrollAmount = 20f
    val containerTop = scrollableContainerBounds.top + parentTopInRoot
    val containerBottom = scrollableContainerBounds.bottom + parentTopInRoot
    while (isActive) {
        val dragPosition = currentDragY
        when {
            dragPosition < containerTop + scrollThresholdPx -> {
                coroutineScope.launch {
                    scrollState.scrollBy(-scrollAmount)
                }
            }
            dragPosition > containerBottom - scrollThresholdPx -> {
                coroutineScope.launch {
                    scrollState.scrollBy(scrollAmount)
                }
            }
            else -> break
        }
        delay(10)
    }
}

オートスクロール機能付きの DnD を用いた並び替え

これでオートスクロールも実装し、標準の並び替え API などと同等以上の挙動を実装することができました 🎉

まとめ

今回は Compose での Darg and Drop の実装とリスト並び替え UI への応用についてご紹介しました。Compose が Stable となって5年近く経過しますが、リストの並び替え API は充実していないのが現状です。Drag and Drop の機能自体は並び替えに適した物ではありませんが、応用次第で高い UX を維持したまま並び替えを実装することができるので、参考になればと思います。

最後に、クックパッドでは現在絶賛採用活動中です。毎日の料理を楽しみにしたい皆様からのご応募をお待ちしております!

cookpad.careers

cookpad.careers

ページネーションを壊さずにInterleavingをする

こんにちは、レシピ事業部検索チームの薄羽です。

検索チームでは日々レシピ検索機能の改善を行っています。

最近行った検索結果改善の過程で、2つの検索結果を交互に配置するinterleavingを実装する機会がありました。今回は、この機能の実装の際の課題となる点や、それをどう解決したのかを紹介します。

Interleaving

Interleavingは2つのランキングを評価する手法であり、2つのランキングの一番上から文書を交互に取っていくことで、ランキングを生成します。

interleaving機能を実装する際、ページネーションを壊さずにinterleavingを実装するには少し工夫が必要です。2つの検索結果から1つの検索結果を作るため、2つのランキングで重複した文書があると、生成される検索結果には同じ文書が2回表示されることになってしまいます。



どうやるか

すでにビズリーチさんがElasticsearchでのinterleavingの取り組みを記事にされています (https://engineering.visional.inc/blog/615/implement-interleaving-for-search-evaluation/)。ビズリーチさんもinterleavingの実装にあたりページネーションの問題にぶつかっていて、その解決のためにinterleavingするページを固定し、すでに表示した文書をキャッシュしておくことで、問題を解決しています。

我々も同様にページネーションの問題にぶつかり、同様にキャッシュを用いましたが、少し別のアプローチを取りました。1ページ目のときだけinterleavingするというアイデアも最初はありましたが、我々のアプリケーションでは検索結果は無限スクロールとなっており、ユーザがページを捲りやすいようになっています。またper_pageも基本20~30と小さいです。2ページ目以降もinterleavingするとなると、2ページ目のinterleavingした結果も保存する必要が出てきてしまいますが、検索結果全てのページをキャッシュするというのはあまり良い方法ではありません。加えて、保守性の観点で1ページ目だけに特別な意味を持たせることをしたくないという理由でページを限定してinterleavingする方法はやめました。

以上を考慮して、我々はページの代わりに「interleavingする片方のランキング」を固定することでページを渡るinterleavingを実装しました。

実装

我々は、片方の検索結果を全て取得してキャッシュに保存するという方法を取りました。 片方の検索結果を全件取得することで、どのページであってももう片方の検索結果から重複する結果を除外した上で検索することが可能になります。

我々のケースでは、interleavingしたい片方のランキングの長さは短く、対象の文書やクエリは限定的で、事前計算されています。また、返却する文書情報も多くないため、検索結果全体をキャッシュしても問題ないと判断しました。

この方法では、最初の検索では2回直列でESへの問い合わせをする必要がありますが、それ以降は通常の検索と並列にキャッシュへのアクセスをすることで、遅延の増加を抑えることができます。

今回、評価のためだけでなくランキングの仕組みの一つとしてinterleavingできるようにしたいため、interleavingを始める位置や長さ・文書の数を柔軟にコントロールできるような機能にしました。そのため、2ページ以降もinterleavingするときの課題として、ページ・per_page、今回はさらにinterleavingを始めるoffsetによって「どっちのランキングの文書から始まるか」が変わり複雑になるということがあります。ここで、単純にページとper_pageのみを考えるのであれば、per_pageとページが共に奇数のときのみ、検索結果の一番上がどっちから始まるかが変わるということがわかります。あとはここにoffsetを考慮して、愚直にどっちから始まるかを計算してあげればよさそうです。

キャッシュを用いた手法では、ページを切り替えるタイミングでキャッシュが切れると検索結果がおかしくなる可能性がありますが、今回のケースではキャッシュのttlを長めにとることができるため、問題にならないと判断しました。

この手法により、ページネーションの整合を保ったままページを渡ったinterleavingを実装することができました。我々のケースではキャッシュの肥大化も問題にならず、特別latencyの増加も認められませんでした。

まとめ

  • Elasticsearchをサーチエンジンとしたinterleavingを実装しました。
  • interleavingをサーバ側でやるとページネーションが壊れてしまうという問題がありますが、片方のランキングを全てキャッシュしておくことで解決しました。

We are hiring!

クックパッドでは現在絶賛採用活動中です。毎日の料理を楽しみにしたい皆様からのご応募を熱烈歓迎しております。まずはいま開いている枠を眺めてみてくださいませ。 https://cookpad.careers/