💦
自動スクロールするLazyColumnを手軽に作成したい!
概要
以下の要件を満たすようなリストを出来るだけ手軽に作成する方法を模索しました。
少し躓いた箇所もあったため備忘録として残しておきます。
- 項目を追加した際に一番下までスクロールされている状態ならばスクロール
- 項目を追加する際にアニメーションをかける
- 並び替えや削除などは考慮しない
一番下までスクロールされている状態
上にスクロールされている状態ならば自動スクロールはしない
コード
最終的なコード
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.wokspace.ui.theme.WokspaceTheme
import java.util.UUID
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WokspaceTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
var text by remember {
mutableStateOf("")
}
val texts = remember {
mutableStateListOf<ListItem>()
}
TextForm(
text = text,
onValueChange = { text = it },
onClickAdd = {
texts.add(0, ListItem(text = it))
text = ""
},
)
AutoScrollList(
texts = texts,
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Gray),
)
}
}
}
}
}
}
data class ListItem(
val text: String,
val id: String = UUID.randomUUID().toString(),
)
@Composable
fun TextForm(
text: String,
onValueChange: (String) -> Unit,
onClickAdd: (String) -> Unit,
) {
Row {
TextField(
value = text,
onValueChange = onValueChange,
)
Button(onClick = { onClickAdd(text) }) {
Text("追加")
}
}
}
@Composable
fun AutoScrollList(
texts: List<ListItem>,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
DisposableEffect(texts.size) {
onDispose {
// 更新される前にこれ以上下にスクロールできない位置(一番下にいる状態)の場合
// 次のレンダリング時に下までスクロールされた状態にしておく
if (!lazyListState.canScrollBackward) {
lazyListState.requestScrollToItem(0)
}
}
}
LazyColumn(
state = lazyListState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(2.dp, alignment = Alignment.Bottom),
modifier = modifier,
) {
items(texts, key = { it.id }) { item ->
Text(
text = item.text,
modifier = Modifier
.background(Color.White)
.animateItem(),
)
}
}
}
補足
1. 項目追加時のアニメーション
LazyColumn
に項目が追加される際のアニメーションはanimateItem
という修飾子で簡単に実装できます。
これはandroidx.compose.animation
の最新の安定版(2024/11/04時点で1.7.0)で使用できます。
implementation("androidx.compose.animation:animation:1.7.0")
2. スクロールとアニメーションの競合
最初はDisposableEffect
ではなくLaunchedEffect
で下記のように実装しようとしました。
LaunchedEffect(texts.size) {
if (lazyListState.canScrollBackward) {
lazyListState.scrollToItem(0)
}
}
しかしながらこの実装だとアニメーションがうまく動きませんでした。
アニメーションが消えてしまう!
しっかりと調べたわけではありませんが、おそらく内部で呼ばれているsnapToItemIndexInternal
のitemAnimator.reset()
でアニメーションが中断されているのだと思います。(間違っていたらすみません)
internal fun snapToItemIndexInternal(
index: Int,
scrollOffset: Int,
forceRemeasure: Boolean
) {
val positionChanged = scrollPosition.index != index ||
scrollPosition.scrollOffset != scrollOffset
// sometimes this method is called not to scroll, but to stay on the same index when
// the data changes, as by default we maintain the scroll position by key, not index.
// when this happens we don't need to reset the animations as from the user perspective
// we didn't scroll anywhere and if there is an offset change for an item, this change
// should be animated.
// however, when the request is to really scroll to a different position, we have to
// reset previously known item positions as we don't want offset changes to be animated.
// this offset should be considered as a scroll, not the placement change.
if (positionChanged) {
itemAnimator.reset()
}
scrollPosition.requestPositionAndForgetLastKnownKey(index, scrollOffset)
if (forceRemeasure) {
remeasurement?.forceRemeasure()
} else {
measurementScopeInvalidator.invalidateScope()
}
}
これを回避するためDisposableEffect
でスクロールをリクエストしておくようにしました。
これによってリストが変更されリコンポーズされる際に既にスクロールされている状態にし、アニメーションを阻害しないようにできました。
感想
手軽にできてよかったなと思いました。
Discussion