Jetpack ComposeのLazyColumn/LazyRowのスクロール速度を制限する
はじめに
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.gestures
にDefaultFlingBehavior
として定義されています。本記事ではこのDefaultFlingBehaviorの実装をベースとします。
方針としては単純でperformFling()
のパラメータであるinitialVelocity
の絶対値を小さく制限します。今回、スクロール速度を制限するFlingBehaviorをVelocityLimitedFlingBehavior
と定義します。おおまかな実装のイメージは次です。
// 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()
を用意します。
@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