🐈

Jetpack ComposeのLazyColumn/LazyRowのスクロール速度を制限する

2024/01/17に公開

はじめに

Jetpack Composeは従来のXMLベースのAndroidViewと比べると、パフォーマンスに関する課題がしばしば話題になります。特に、Composeで実装したリスト表示でUIのもたつきを感じたことがある開発者の方は多いのではないでしょうか?

Composeのパフォーマンス関連のトピックは公式のドキュメントや各種多くの技術記事でまとめられています。例えば、公式のパフォーマンスガイド6 Jetpack Compose Guidelines to Optimize Your App Performanceなどといった資料はパフォーマンスを意識した開発のガイドラインを提供しています。また、Baseline Profilesの導入など、さまざまな改善策もあります。しかし、これらの改善を施しても古い端末などでは、Composeによるリスト表示時の画面のもたつきが依然として問題となることがあります。

そこで本記事ではUI表示のラグを防ぐための別のアプローチとして、Jetpack Composeでリスト表示を行うLazyColumn/LazyRowのスクロール速度を制限する方法について紹介します。

LazyColumn/LazyRowのスクロール速度を制限するために

早速、LazyColumn/LazyRowのスクロール速度を制限する方法について考えていきましょう。本記事では以降LazyColumnに注目します。LazyColumnの実装を見てみるとcompose-foundation 1.5.4時点では直接的にスクロール速度を直接的に制限する方法は用意されていません。

一方、外部から変更可能な要素としてFlingBehaviorがあります。

FlingBehaviorは、高速スクロール、すなわちフリングの動作を定義するインターフェースです。FlingBehavior.ktのコードを確認すると、ユーザーのドラッグ操作が終了した時にFlingBehaviorのperformFling()メソッドが呼ばれ、これによって高速スクロールが実行されます。LazyColumnやLazyRowでは、フリングの挙動を調整するために、このFlingBehaviorを設定できるflingBehaviorパラメータが用意されています。

つまり、スクロール速度もといフリングの速度を制限したい場合は、FlingBehaviorをカスタマイズすることで実現できます。

FlingBehaviorをカスタマイズする

FlingBehaviorをカスタマイズしてスクロール速度を制限する方法について考えます。LazyColumnで適用されるFlingBehaviorのデフォルトの実装はandroidx.compose.foundation.gesturesDefaultFlingBehaviorとして定義されています。本記事ではこのDefaultFlingBehaviorの実装をベースとします。

方針としては単純でperformFling()のパラメータであるinitialVelocityの絶対値を小さく制限します。今回、スクロール速度を制限するFlingBehaviorをVelocityLimitedFlingBehaviorと定義します。おおまかな実装のイメージは次です。

VelocityLimitedFlingBehavior.kt
// 1. FlingBehaviorを実装したVelocityLimitedFlingBehaviorを定義
private class VelocityLimitedFlingBehavior(
    // 2. スクロール速度の最大制限値
    private val velocityLimit: Float,
    private val flingDecay: DecayAnimationSpec<Float>,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        // 3. initialVelocityをvelocityLimitの範囲内に制限
        val limitedVelocity = initialVelocity.coerceIn(-velocityLimit, velocityLimit)

        // 以降の処理でinitialVelocityのかわりにlimitedVelocityを使う
        return /* ... */
    }
}

後はライブラリの実装と同様に、FlingBehaviorを実装したクラスのインスタンスをrememberにて保持します。

スクロール速度の最大制限値の設定

問題となるのがスクロール速度の最大制限値であるvelocityLimitの設定です。performFling()の引数であるinitialVelocityはユーザーの操作だけでなく、端末の画面密度によっても左右されるため、velocityLimitを一律の定数で設定することにはリスクが伴います。異なる端末での一貫した体験を提供するためには、端末ごとに最適化されたvelocityLimitの値を動的に設定する必要があります。

そこで今回はAndroidのUIテストフレームワークであるUI Automatorのアプローチを参考にします。UI Automatorは、システムアプリとインストール済みアプリにまたがるアプリ間のUI機能テストに適したUIテストフレームワークです。UI Automatorはデバイス上のUIコンポーネントへのアクセスや、コンポーネントのクリックやスクロール、フリングなどの操作の処理に対応しています。

そしてUI Automatorのフリングの実装では、

  • デフォルトの速度値(float)
  • 画面の密度をdpiで表した値であるdensityDpiをAndroidシステムにおける標準とされる画面密度であるDisplayMetrics.DENSITY_DEFAULTで割った値であるdisplayDensity

を掛け合わせた値がフリングのデフォルトの速度と定義されています(詳しい実装はUiObject2に記述されています)。これをJetpack Composeで表すと次のようになります。

スクロール速度の最大制限値の計算
import android.util.DisplayMetrics
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext

@Composable
fun getDefaultFlingVelocity(
    // デフォルトのスピード
    defaultFlingSpeed: Float = 7500f
): Float {
    // displayDensityの計算
    val context = LocalContext.current
    val densityDpi = context.resources.configuration.densityDpi

    val displayDensity = densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()

    // デフォルトのフリング速度の算出
    return defaultFlingSpeed * displayDensity
}

今回はgetDefaultFlingVelocity()の値をスクロール速度の最大制限値と設定します。

スクロール速度を制限する

以上を踏まえて、スクロール速度を制限したVelocityLimitedFlingBehaviorの実装を示します。基本的にはライブラリの実装と先述の説明を参考に、新たにVelocityLimitedFlingBehaviorを返すrememberVelocityLimitedFlingBehavior()を用意します。

VelocityLimitedFlingBehavior.kt
@Composable
fun rememberVelocityLimitedFlingBehavior(
    defaultFlingSpeed: Float = 7500f
): FlingBehavior {
    // スクロール速度の最大制限値の計算
    val context = LocalContext.current
    val displayDensity = context.resources.configuration.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()
    val velocityLimit = defaultFlingSpeed * displayDensity

    val flingSpec = rememberSplineBasedDecay<Float>()

    return remember(velocityLimit, flingSpec) {
        VelocityLimitedFlingBehavior(
            velocityLimit = velocityLimit,
            flingDecay = flingSpec
        )
    }
}

private class VelocityLimitedFlingBehavior(
    private val velocityLimit: Float,
    private val flingDecay: DecayAnimationSpec<Float>,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        // initialVelocityをvelocityLimitの範囲内に制限
        val limitedVelocity = initialVelocity.coerceIn(-velocityLimit, velocityLimit)

        // initialVelocityのかわりにlimitedVelocityを使う
        return withContext(defaultScrollMotionDurationScale) {
            if (abs(limitedVelocity) > 1f) {
                var velocityLeft = limitedVelocity
                var lastValue = 0f
                AnimationState(
                    initialValue = 0f,
                    initialVelocity = limitedVelocity,
                ).animateDecay(flingDecay) {
                    val delta = value - lastValue
                    val consumed = scrollBy(delta)
                    lastValue = value
                    velocityLeft = this.velocity

                    if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
                }
                velocityLeft
            } else {
                limitedVelocity
            }
        }
    }

    private val defaultScrollMotionDurationScaleFactor = 1f

    val defaultScrollMotionDurationScale = object : MotionDurationScale {
        override val scaleFactor: Float
            get() = defaultScrollMotionDurationScaleFactor
    }
}

そしてLazyColumn/LazyRowのflingBehaviorでrememberVelocityLimitedFlingBehavior()を呼びます。必要に応じてスクロール速度の最大制限値を変更しましょう。

呼び出しもと
LazyColumn(
    flingBehavior = rememberVelocityLimitedFlingBehavior(),
) {
    /* ... */
}

LazyRow(
    flingBehavior = rememberVelocityLimitedFlingBehavior(),
) {
    /* ... */
}

まとめ

今回、Jetpack ComposeのLazyColumn/LazyRowのスクロール速度を制限する方法について紹介しました。Androidアプリのパフォーマンス向上には様々なアプローチがありますが、スクロール速度の制限が効果的な場面もあるかもしれません。

参考

Discussion