scroll方式を試行錯誤しながら、
最終形へ持っていった経緯をまとめた“試行の記録”です。
"思考の記録"とも言う![]()
関連記事:
LaunchedEffect 単体方式の破綻
差分を Compose 側の値(inset / viewport)で取る。
1. inset
class Ref<T>(var value: T)
val previousInset = remember { Ref(0f) }
val currentInset = WindowInsets.ime.getBottom((LocalDensity.current)).toFloat()
LaunchedEffect(currentInset) {
val diff = currentInset - previousInset.value
listState.scrollBy(diff)
Log.d("",
"currentInset=${currentInset} " + // IME の高さ
"previousInset=${previousInset.value} " + // 前回の再コンポーズ時の IME の高さ
"diff=${diff}" // IME の高さ変化量
)
previousInset.value = currentInset
}
数字も計算も間違っていない(と思う)けど、以下のケースで
アニメーション終了時の位置がなぜか想定位置にならない。
- LazyColumn末尾でIMEを閉じたとき
- LazyColumnの途中でIMEを開閉したとき
実行時のログ
currentInset=190.0 previousInset=0.0 diff=190.0
currentInset=473.0 previousInset=190.0 diff=283.0
currentInset=623.0 previousInset=473.0 diff=150.0
currentInset=705.0 previousInset=623.0 diff=82.0
currentInset=759.0 previousInset=705.0 diff=54.0
currentInset=778.0 previousInset=759.0 diff=19.0
currentInset=792.0 previousInset=778.0 diff=14.0
currentInset=811.0 previousInset=792.0 diff=19.0
currentInset=816.0 previousInset=811.0 diff=5.0
currentInset=821.0 previousInset=816.0 diff=5.0
currentInset=785.0 previousInset=821.0 diff=-36.0
currentInset=630.0 previousInset=785.0 diff=-155.0
currentInset=347.0 previousInset=630.0 diff=-283.0
currentInset=262.0 previousInset=347.0 diff=-85.0
currentInset=200.0 previousInset=262.0 diff=-62.0
currentInset=152.0 previousInset=200.0 diff=-48.0
currentInset=86.0 previousInset=152.0 diff=-66.0
currentInset=62.0 previousInset=86.0 diff=-24.0
currentInset=43.0 previousInset=62.0 diff=-19.0
currentInset=28.0 previousInset=43.0 diff=-15.0
currentInset=17.0 previousInset=28.0 diff=-11.0
currentInset=9.0 previousInset=17.0 diff=-8.0
currentInset=4.0 previousInset=9.0 diff=-5.0
currentInset=1.0 previousInset=4.0 diff=-3.0
currentInset=0.0 previousInset=1.0 diff=-1.0
インセットの値は、コンポジション フェーズの後、レイアウト フェーズの前に更新されます。つまり、コンポジションでインセットの値を読み取る場合、通常は 1 フレーム遅れたインセットの値が使用されます。(引用元:Android Developers)
inset はアニメーション中に途中値を返すため、差分方式としては不安定。
そのため、別の手段で差分を作ることにする。
2. viewport
var lastViewportHeight by remember { mutableIntStateOf(0) }
val current = listState.layoutInfo.viewportSize.height
LaunchedEffect(listState.layoutInfo.viewportSize.height) {
if (lastViewportHeight != 0) {
val diff = lastViewportHeight - current
Log.d("before ",
"viewHeight=${current} " + // 今の viewport の高さ(IME 開閉で変化する)
"lastViewportHeight=${lastViewportHeight} " + // 前回の viewport の高さ(差分計算の基準)
"index=${listState.firstVisibleItemIndex} " + // 画面の一番上に見えてるアイテムの番号
"offset=${listState.firstVisibleItemScrollOffset} " + // その index のアイテムのスクロール位置
"start=${listState.layoutInfo.viewportStartOffset} " + // viewport の上端(ほぼ 0)
"end=${listState.layoutInfo.viewportEndOffset} " + // viewport の下端(=viewHeight)
"diff=${diff} " + // viewport の高さ変化量(IME の開閉量)
"last_offset=${listState.layoutInfo.visibleItemsInfo.last().offset} " + // 最後のアイテムの表示開始位置
"last_size=${listState.layoutInfo.visibleItemsInfo.last().size} " // 最後のアイテムの高さ
)
listState.scrollBy(diff.toFloat())
Log.d("after ",
"viewHeight=${current} " + // 今の viewport の高さ(IME 開閉で変化する)
"lastViewportHeight=${lastViewportHeight} " + // 前回の viewport の高さ(差分計算の基準)
"index=${listState.firstVisibleItemIndex} " + // 画面の一番上に見えてるアイテムの番号
"offset=${listState.firstVisibleItemScrollOffset} " + // その index のアイテムのスクロール位置
"start=${listState.layoutInfo.viewportStartOffset} " + // viewport の上端(ほぼ 0)
"end=${listState.layoutInfo.viewportEndOffset} " + // viewport の下端(=viewHeight)
"diff=${diff} " + // viewport の高さ変化量(IME の開閉量)
"last_offset=${listState.layoutInfo.visibleItemsInfo.last().offset} " + // 最後のアイテムの表示開始位置
"last_size=${listState.layoutInfo.visibleItemsInfo.last().size} " // 最後のアイテムの高さ
)
}
lastViewportHeight = current
}
数字も計算も間違っていない(と思う)けど、以下のケースで
アニメーション終了時の位置がなぜか想定位置にならない。
- LazyColumn末尾でIMEを閉じたとき
実行時のログ
before viewHeight=1946 lastViewportHeight=1999 index=10 offset=10833 start=0 end=1658 diff=53 last_offset=-10833 last_size=12832
after viewHeight=1946 lastViewportHeight=1999 index=10 offset=10886 start=0 end=1658 diff=53 last_offset=-10886 last_size=12832
before viewHeight=1658 lastViewportHeight=1946 index=10 offset=10886 start=0 end=1572 diff=288 last_offset=-10886 last_size=12832
after viewHeight=1658 lastViewportHeight=1946 index=10 offset=11174 start=0 end=1572 diff=288 last_offset=-11174 last_size=12832
before viewHeight=1572 lastViewportHeight=1658 index=10 offset=11174 start=0 end=1462 diff=86 last_offset=-11174 last_size=12832
after viewHeight=1572 lastViewportHeight=1658 index=10 offset=11260 start=0 end=1462 diff=86 last_offset=-11260 last_size=12832
before viewHeight=1462 lastViewportHeight=1572 index=10 offset=11260 start=0 end=1391 diff=110 last_offset=-11260 last_size=12832
after viewHeight=1462 lastViewportHeight=1572 index=10 offset=11370 start=0 end=1391 diff=110 last_offset=-11370 last_size=12832
before viewHeight=1391 lastViewportHeight=1462 index=10 offset=11370 start=0 end=1367 diff=71 last_offset=-11370 last_size=12832
after viewHeight=1391 lastViewportHeight=1462 index=10 offset=11441 start=0 end=1367 diff=71 last_offset=-11441 last_size=12832
before viewHeight=1367 lastViewportHeight=1391 index=10 offset=11441 start=0 end=1333 diff=24 last_offset=-11441 last_size=12832
after viewHeight=1367 lastViewportHeight=1391 index=10 offset=11465 start=0 end=1333 diff=24 last_offset=-11465 last_size=12832
before viewHeight=1333 lastViewportHeight=1367 index=10 offset=11465 start=0 end=1322 diff=34 last_offset=-11465 last_size=12832
after viewHeight=1333 lastViewportHeight=1367 index=10 offset=11499 start=0 end=1322 diff=34 last_offset=-11499 last_size=12832
before viewHeight=1322 lastViewportHeight=1333 index=10 offset=11499 start=0 end=1314 diff=11 last_offset=-11499 last_size=12832
after viewHeight=1322 lastViewportHeight=1333 index=10 offset=11510 start=0 end=1314 diff=11 last_offset=-11510 last_size=12832
before viewHeight=1314 lastViewportHeight=1322 index=10 offset=11510 start=0 end=1306 diff=8 last_offset=-11510 last_size=12832
after viewHeight=1314 lastViewportHeight=1322 index=10 offset=11518 start=0 end=1306 diff=8 last_offset=-11518 last_size=12832
before viewHeight=1306 lastViewportHeight=1314 index=10 offset=11518 start=0 end=1304 diff=8 last_offset=-11518 last_size=12832
after viewHeight=1306 lastViewportHeight=1314 index=10 offset=11526 start=0 end=1304 diff=8 last_offset=-11526 last_size=12832
before viewHeight=1304 lastViewportHeight=1306 index=10 offset=11526 start=0 end=1304 diff=2 last_offset=-11526 last_size=12832
after viewHeight=1304 lastViewportHeight=1306 index=10 offset=11528 start=0 end=1304 diff=2 last_offset=-11528 last_size=12832
before viewHeight=1340 lastViewportHeight=1304 index=10 offset=11337 start=0 end=1495 diff=-36 last_offset=-11337 last_size=12832
after viewHeight=1340 lastViewportHeight=1304 index=10 offset=11301 start=0 end=1495 diff=-36 last_offset=-11301 last_size=12832
before viewHeight=1495 lastViewportHeight=1340 index=10 offset=11054 start=0 end=1778 diff=-155 last_offset=-11054 last_size=12832
after viewHeight=1495 lastViewportHeight=1340 index=10 offset=10899 start=0 end=1778 diff=-155 last_offset=-10899 last_size=12832
before viewHeight=1778 lastViewportHeight=1495 index=10 offset=10899 start=0 end=1863 diff=-283 last_offset=-10899 last_size=12832
after viewHeight=1778 lastViewportHeight=1495 index=10 offset=10616 start=0 end=1863 diff=-283 last_offset=-10616 last_size=12832
before viewHeight=1863 lastViewportHeight=1778 index=10 offset=10616 start=0 end=1925 diff=-85 last_offset=-10616 last_size=12832
after viewHeight=1863 lastViewportHeight=1778 index=10 offset=10531 start=0 end=1925 diff=-85 last_offset=-10531 last_size=12832
before viewHeight=1925 lastViewportHeight=1863 index=10 offset=10531 start=0 end=1973 diff=-62 last_offset=-10531 last_size=12832
after viewHeight=1925 lastViewportHeight=1863 index=10 offset=10469 start=0 end=1973 diff=-62 last_offset=-10469 last_size=12832
before viewHeight=1973 lastViewportHeight=1925 index=10 offset=10469 start=0 end=1999 diff=-48 last_offset=-10469 last_size=12832
after viewHeight=1973 lastViewportHeight=1925 index=10 offset=10421 start=0 end=1999 diff=-48 last_offset=-10421 last_size=12832
before viewHeight=1999 lastViewportHeight=1973 index=10 offset=10421 start=0 end=1999 diff=-26 last_offset=-10421 last_size=12832
after viewHeight=1999 lastViewportHeight=1973 index=10 offset=10395 start=0 end=1999 diff=-26 last_offset=-10395 last_size=12832
計算式も数値も不審な箇所がないのなら、
疑うのはイベント(LaunchedEffect)の発火タイミングしかないじゃない![]()
というわけで、AndroidViewを使用した、正確なタイミングを取りにいくことにする。
AndroidView + LaunchedEffect 方式の破綻
差分を OS 側イベント(WindowInsetsAnimation)で取る。
3. eventflag
//▼AndroidViewでIME開閉動作開始/終了のイベントを捕まえる
val maxInset = remember { Ref(0f) }
val directionSign = remember { Ref(+1) }
val isStart = remember { Ref(false) }
val isScroll = remember { Ref(false) }
val isEnd = remember { Ref(false) }
val previousFraction = remember { Ref(0f) }
val previousDistance = remember { Ref(0f) }
var distance by remember { mutableFloatStateOf(0f) }
AndroidView(
modifier = Modifier.Companion
.fillMaxSize()
.alpha(0f), // 完全透過(当たり判定なし)
factory = { context ->
View(context).apply {
ViewCompat.setWindowInsetsAnimationCallback(
this,
object: WindowInsetsAnimationCompat.Callback(
DISPATCH_MODE_CONTINUE_ON_SUBTREE
) {
override fun onPrepare(
animation: WindowInsetsAnimationCompat
) {
super.onPrepare(animation)
val isVisibleIme = ViewCompat.getRootWindowInsets(this@apply)
?.isVisible(animation.typeMask and WindowInsetsCompat.Type.ime())
if (isVisibleIme == false) {
directionSign.value = +1
} else {
directionSign.value = -1
}
Log.d("onPrepare", "isVisibleIme=${isVisibleIme}")
}
override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat
): WindowInsetsAnimationCompat.BoundsCompat {
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
maxInset.value =
(bounds.upperBound.bottom - bounds.lowerBound.bottom).toFloat()
distance = maxInset.value * animation.fraction
isStart.value = true
Log.d("onStart", "distance=${distance}")
}
return bounds
}
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: List<WindowInsetsAnimationCompat>
): WindowInsetsCompat {
val imeAnimation = runningAnimations.find {
it.typeMask and WindowInsetsCompat.Type.ime() != 0
}
if (imeAnimation != null) {
val fraction = imeAnimation.interpolatedFraction
val delta = fraction - previousFraction.value
if ((delta > 0.02f) or (fraction == 1.0f)) {
isScroll.value = true
distance =
maxInset.value * fraction
previousFraction.value = fraction
Log.d("onProgress", "distance=${distance}")
}
}
return insets
}
// ▼onProgress()直後に呼ばれる(LaunchedEffectよりも早く)
override fun onEnd(
animation: WindowInsetsAnimationCompat
){
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
isEnd.value = true
distance = maxInset.value * animation.fraction
Log.d("onEnd", "distance=${distance}")
}
}
}
)
}
}
)
//▼IMEに追従してLazyColumnのスクロール位置補正
LaunchedEffect(distance) {
if (isStart.value == true) {
previousDistance.value = distance
isStart.value = false
isScroll.value = false
previousFraction.value = 0f
Log.d("isStart", "previousDistance=${previousDistance.value}")
isEnd.value = false
}
if (isScroll.value == true) {
val tmp =
listState.scrollBy(directionSign.value * (distance - previousDistance.value))
Log.d(
"isScroll",
"tmp=${tmp}, previousDistance=${previousDistance.value}, distance=${distance}"
)
previousDistance.value += (directionSign.value * tmp)
isScroll.value = false
}
if (isEnd.value == true) {
val tmp = listState.scrollBy(directionSign.value * (distance - previousDistance.value))
Log.d("isEnd", "tmp=${tmp}, previousDistance=${previousDistance.value}, distance=${distance}")
previousDistance.value += (directionSign.value * tmp)
isEnd.value = false
}
}
数字も計算も間違っていない(と思う)けど、以下のケースで
アニメーション終了時の位置がなぜか想定位置にならない。
- LazyColumn末尾でIMEを開閉したとき
- LazyColumnの途中でIMEを開閉したとき
実行時のログ
onPrepare isVisibleIme=false
onStart distance=0.0
onProgress distance=35.733105
onProgress distance=190.92627
isStart previousDistance=190.92627
onProgress distance=479.90775
isScroll tmp=288.98148, previousDistance=190.92627, distance=479.90775
onProgress distance=620.3834
isScroll tmp=140.47568, previousDistance=479.90775, distance=620.3834
onProgress distance=705.9918
isScroll tmp=85.6084, previousDistance=620.3834, distance=705.9918
onProgress distance=735.96844
isScroll tmp=29.976624, previousDistance=705.9918, distance=735.96844
onProgress distance=759.7538
isScroll tmp=23.78534, previousDistance=735.96844, distance=759.7538
onProgress distance=792.19476
isScroll tmp=32.44098, previousDistance=759.7538, distance=792.19476
onProgress distance=811.9358
isScroll tmp=19.741028, previousDistance=792.19476, distance=811.9358
onProgress distance=821.0
onEnd distance=821.0
isScroll tmp=9.064209, previousDistance=811.9358, distance=821.0
isEnd tmp=0.0, previousDistance=821.0, distance=821.0
onPrepare isVisibleIme=true
onStart distance=0.0
isStart previousDistance=0.0
onProgress distance=35.733105
isScroll tmp=-35.733105, previousDistance=0.0, distance=35.733105
onProgress distance=361.95172
isScroll tmp=-326.21863, previousDistance=35.733105, distance=361.95172
onProgress distance=479.90775
isScroll tmp=-117.956024, previousDistance=361.95172, distance=479.90775
onProgress distance=558.31
isScroll tmp=-78.40225, previousDistance=479.90775, distance=558.31
onProgress distance=668.50354
isScroll tmp=-110.19354, previousDistance=558.31, distance=668.50354
onProgress distance=705.9918
isScroll tmp=-37.48828, previousDistance=668.50354, distance=705.9918
onProgress distance=735.96844
onProgress distance=759.7538
isScroll tmp=-53.761963, previousDistance=705.9918, distance=759.7538
onProgress distance=777.4389
isScroll tmp=-17.68512, previousDistance=759.7538, distance=777.4389
onProgress distance=803.4847
isScroll tmp=-26.045776, previousDistance=777.4389, distance=803.4847
onProgress distance=821.0
onEnd distance=821.0
isScroll tmp=-17.51532, previousDistance=803.4847, distance=821.0
isEnd tmp=-0.0, previousDistance=821.0, distance=821.0
WindowInsetsAnimation のイベント順は OS が決める
1. onProgress
2. onEnd
3. isScroll(LaunchedEffect)
4. isEnd(LaunchedEffect)
この順番で呼ばれていることは、ログから確定できる。
maxInset × fraction が端末依存で不安定
maxInset.value = (bounds.upperBound.bottom - bounds.lowerBound.bottom).toFloat()
これは WindowInsetsAnimation の bounds に依存した IME 高さの推定だけど、
bounds は端末・OS・IME の種類で値が変わるため、
実際の IME 高さと一致しないケースが多い。
WindowInsetsAnimationのイベント非同期 と
maxInset×fraction方式の不安定さ から、
eventflag+LaunchedEffect側での最終調整は構造的に破綻している。
補正ロジックはAndroidView側に全倒しするのが安全策。
AndroidView 単体方式
全ての処理を OS 側イベント(WindowInsetsAnimation) で行う。
4. dispatchRawDelta
AndroidView(
modifier = Modifier.Companion
.fillMaxSize()
.alpha(0.0f), // 完全透過(当たり判定なし)
factory = { context ->
View(context).apply {
//▼IMEのアニメーションを拾うためのコールバック
ViewCompat.setWindowInsetsAnimationCallback(
this,
object: WindowInsetsAnimationCompat.Callback(
DISPATCH_MODE_CONTINUE_ON_SUBTREE
) {
override fun onPrepare(
animation: WindowInsetsAnimationCompat
) {
super.onPrepare(animation)
// アニメーション開始前に変数を初期化
previousFraction.value = 0.0f
previousInset.value = null
finalExpectedDelta.value = 0.0f
}
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: List<WindowInsetsAnimationCompat>
): WindowInsetsCompat {
// 今動いているIMEのアニメーションを取得
val imeAnimation = runningAnimations.find {
it.typeMask and WindowInsetsCompat.Type.ime() != 0
}
if (imeAnimation != null) {
val fraction = imeAnimation.interpolatedFraction
val delta = fraction - previousFraction.value
// IME+ナビゲーションバーのbottomを取得
val currentInset = insets.getInsets(
WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.navigationBars()
).bottom.toFloat()
if (previousInset.value == null) {
// 初回は基準値として保持
previousInset.value = currentInset
} else if ((delta > 0.02f) or (fraction == 1.0f)) {
// deltaで間引きながらスクロールを適用
val scrollDelta =
listState.dispatchRawDelta(currentInset - previousInset.value!!)
Log.d(
"onProgress",
"fraction=${fraction}, scrollDelta=${scrollDelta}, currentInset=${currentInset}, previousInset=${previousInset.value}"
)
// スクロール適用時点のfractionとinsetを代入
previousFraction.value = fraction
previousInset.value = previousInset.value!! + scrollDelta
}
if (fraction == 1.0f) {
// アニメーション終了時点での「最終的に必要な差分」を記録
finalExpectedDelta.value =
currentInset - previousInset.value!!
}
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
// 最終的に残った差分をもう一度適用して位置調整を行う
val scrollDelta =
listState.dispatchRawDelta(finalExpectedDelta.value)
Log.d(
"onEnd",
"scrollDelta=${scrollDelta}, finalExpectedDelta=${finalExpectedDelta.value}"
)
}
}
)
}
}
)
以下のケースで
アニメーション終了時の位置が想定位置になることを確認。
- LazyColumn末尾でIMEを開閉したとき
- LazyColumnの途中でIMEを開閉したとき
実行時のログ
onProgress fraction=0.043523878, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=0.23255332, scrollDelta=-0.0, currentInset=190.0, previousInset=126.0
onProgress fraction=0.576855, scrollDelta=64.0, currentInset=473.0, previousInset=126.0
onProgress fraction=0.7556436, scrollDelta=283.0, currentInset=620.0, previousInset=190.0
onProgress fraction=0.859917, scrollDelta=147.0, currentInset=705.0, previousInset=473.0
onProgress fraction=0.8964293, scrollDelta=85.0, currentInset=735.0, previousInset=620.0
onProgress fraction=0.9238281, scrollDelta=30.0, currentInset=758.0, previousInset=705.0
onProgress fraction=0.96491444, scrollDelta=23.0, currentInset=792.0, previousInset=735.0
onProgress fraction=0.98895955, scrollDelta=45.0, currentInset=811.0, previousInset=758.0
onProgress fraction=1.0, scrollDelta=16.0, currentInset=821.0, previousInset=803.0
onEnd scrollDelta=-0.0, finalExpectedDelta=2.0
onProgress fraction=0.038190294, scrollDelta=-32.0, currentInset=789.0, previousInset=821.0
onProgress fraction=0.21868287, scrollDelta=-148.0, currentInset=641.0, previousInset=789.0
onProgress fraction=0.43101135, scrollDelta=-174.0, currentInset=467.0, previousInset=641.0
onProgress fraction=0.576855, scrollDelta=-120.0, currentInset=347.0, previousInset=467.0
onProgress fraction=0.7516256, scrollDelta=-144.0, currentInset=203.0, previousInset=347.0
onProgress fraction=0.8113777, scrollDelta=-49.0, currentInset=154.0, previousInset=203.0
onProgress fraction=0.8575219, scrollDelta=-28.0, currentInset=126.0, previousInset=154.0
onProgress fraction=0.89446723, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=0.9238281, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=0.94694144, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=0.978052, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=0.9982162, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onProgress fraction=1.0, scrollDelta=0.0, currentInset=126.0, previousInset=126.0
onEnd scrollDelta=0.0, finalExpectedDelta=0.0
IME アニメーションの途中値・イベント順・発火タイミングの問題を全て回避するため、
最終的には AndroidView 側で IME の実測値を使い、
dispatchRawDelta で直接補正する方式が最も安定する。