diff --git a/redwood-lazylayout-compose/build.gradle b/redwood-lazylayout-compose/build.gradle index 0ed6f7fbf5..e62143e566 100644 --- a/redwood-lazylayout-compose/build.gradle +++ b/redwood-lazylayout-compose/build.gradle @@ -13,6 +13,7 @@ kotlin { sourceSets { commonMain { dependencies { + api libs.jetbrains.compose.runtime.saveable api projects.redwoodLazylayoutWidget } } diff --git a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt index c02d13480d..e1f8d9d2e9 100644 --- a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt +++ b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyList.kt @@ -48,7 +48,6 @@ internal fun LazyList( var lastVisibleItemIndex by remember { mutableStateOf(0) } val itemsBefore = remember(state.firstVisibleItemIndex) { (state.firstVisibleItemIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0) } val itemsAfter = remember(lastVisibleItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastVisibleItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) } - val scrollItemIndex = remember(state.scrollToItemTriggeredId) { ScrollItemIndex(state.scrollToItemTriggeredId, state.firstVisibleItemIndex) } // TODO(jwilson): drop this down to 20 once this is fixed: // https://github.com/cashapp/redwood/issues/1551 var placeholderPoolSize by remember { mutableStateOf(30) } @@ -57,13 +56,14 @@ internal fun LazyList( itemsBefore = itemsBefore, itemsAfter = itemsAfter, onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex -> + state.onScrolled(localFirstVisibleItemIndex) + val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2 // We only ever want to increase the pool size. if (placeholderPoolSize < proposedPlaceholderPoolSize) { placeholderPoolSize = proposedPlaceholderPoolSize } - state.firstVisibleItemIndex = localFirstVisibleItemIndex lastVisibleItemIndex = localLastVisibleItemIndex }, width = width, @@ -71,7 +71,7 @@ internal fun LazyList( margin = margin, crossAxisAlignment = crossAxisAlignment, modifier = modifier, - scrollItemIndex = scrollItemIndex, + scrollItemIndex = ScrollItemIndex(0, state.scrollItemIndex), placeholder = { repeat(placeholderPoolSize) { placeholder() } }, items = { for (index in itemsBefore until itemProvider.itemCount - itemsAfter) { @@ -102,20 +102,20 @@ internal fun RefreshableLazyList( var lastVisibleItemIndex by remember { mutableStateOf(0) } val itemsBefore = remember(state.firstVisibleItemIndex) { (state.firstVisibleItemIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0) } val itemsAfter = remember(lastVisibleItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastVisibleItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) } - val scrollItemIndex = remember(state.scrollToItemTriggeredId) { ScrollItemIndex(state.scrollToItemTriggeredId, state.firstVisibleItemIndex) } var placeholderPoolSize by remember { mutableStateOf(20) } RefreshableLazyList( isVertical, itemsBefore = itemsBefore, itemsAfter = itemsAfter, onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex -> + state.onScrolled(localFirstVisibleItemIndex) + val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2 // We only ever want to increase the pool size. if (placeholderPoolSize < proposedPlaceholderPoolSize) { placeholderPoolSize = proposedPlaceholderPoolSize } - state.firstVisibleItemIndex = localFirstVisibleItemIndex lastVisibleItemIndex = localLastVisibleItemIndex }, refreshing = refreshing, @@ -125,7 +125,7 @@ internal fun RefreshableLazyList( margin = margin, crossAxisAlignment = crossAxisAlignment, modifier = modifier, - scrollItemIndex = scrollItemIndex, + scrollItemIndex = ScrollItemIndex(0, state.scrollItemIndex), placeholder = { repeat(placeholderPoolSize) { placeholder() } }, pullRefreshContentColor = pullRefreshContentColor, items = { diff --git a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt index 5975f27c58..ba42c36949 100644 --- a/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt +++ b/redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt @@ -18,32 +18,71 @@ package app.cash.redwood.lazylayout.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @Composable -public fun rememberLazyListState( - initialFirstVisibleItemIndex: Int = 0, -): LazyListState { - return remember { - LazyListState( - initialFirstVisibleItemIndex, - ) +public fun rememberLazyListState(): LazyListState { + return rememberSaveable(saver = LazyListState.Saver) { + LazyListState() } } -public class LazyListState( - firstVisibleItemIndex: Int = 0, -) { - public var firstVisibleItemIndex: Int by mutableStateOf(firstVisibleItemIndex) +public class LazyListState { + /** We only restore the scroll position once. */ + private var hasRestoredScrollPosition = false + + /** The scroll position to restore. */ + private var restoredIndex: Int = -1 + + /** + * The value published to the host platform. This starts as 0 and changes exactly once to + * trigger exactly one scroll. + */ + public var scrollItemIndex: Int by mutableStateOf(0) internal set - internal var scrollToItemTriggeredId by mutableStateOf(0) + public var firstVisibleItemIndex: Int = 0 + private set + + public fun restoreIndex(index: Int) { + require(index >= 0) + + if (this.restoredIndex != -1) return + this.restoredIndex = index + + // Scroll to the target item. + if (hasRestoredScrollPosition) { + scrollItemIndex = restoredIndex + } + } - public fun scrollToItem( - index: Int, - ) { - firstVisibleItemIndex = index - scrollToItemTriggeredId++ + public fun maybeRestoreScrollPosition() { + if (this.hasRestoredScrollPosition) return + this.hasRestoredScrollPosition = true + + // Scroll to the target item. + if (restoredIndex != -1) { + scrollItemIndex = restoredIndex + } + } + + public fun onScrolled(firstVisibleItemIndex: Int) { + this.firstVisibleItemIndex = firstVisibleItemIndex + } + + public companion object { + /** + * The default [Saver] implementation for [LazyListState]. + */ + public val Saver: Saver = Saver( + save = { it.firstVisibleItemIndex }, + restore = { + LazyListState().apply { + restoreIndex(it) + } + }, + ) } } diff --git a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt index 51a21cc95e..c28013ead2 100644 --- a/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt +++ b/redwood-lazylayout-view/src/main/kotlin/app/cash/redwood/lazylayout/view/ViewLazyList.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import androidx.annotation.ColorInt +import androidx.core.view.children import androidx.core.view.doOnDetach import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative @@ -58,6 +59,8 @@ internal open class ViewLazyList private constructor( private var crossAxisAlignment = CrossAxisAlignment.Start + private var userHasScrolled = false + private val density = Density(recyclerView.context.resources) private val linearLayoutManager = object : LinearLayoutManager(recyclerView.context) { override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams? = when (orientation) { @@ -101,9 +104,11 @@ internal open class ViewLazyList private constructor( addOnScrollListener( object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val viewsOnScreen = recyclerView.children.map { recyclerView.getChildViewHolder(it).bindingAdapterPosition } + userHasScrolled = true // Prevent guest code from hijacking the scrollbar. onViewportChanged?.invoke( - linearLayoutManager.findFirstVisibleItemPosition(), - linearLayoutManager.findLastVisibleItemPosition(), + viewsOnScreen.min(), + viewsOnScreen.max(), ) } }, @@ -154,6 +159,7 @@ internal open class ViewLazyList private constructor( } override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) { + if (userHasScrolled) return recyclerView.scrollToPosition(scrollItemIndex.index) } diff --git a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt index 115308fbc5..404b899256 100644 --- a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt +++ b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt @@ -98,6 +98,12 @@ private fun LazyColumn( var searchTerm by rememberSaveable(stateSaver = searchTermSaver) { mutableStateOf(TextFieldState("")) } + val lazyListState = rememberLazyListState() + + LaunchedEffect(searchTerm) { + lazyListState.restoreIndex(0) + } + LaunchedEffect(refreshSignal) { try { refreshing = true @@ -108,7 +114,9 @@ private fun LazyColumn( val labelToUrl = Json.decodeFromString>(emojisJson) allEmojis.clear() - allEmojis.addAll(labelToUrl.map { (key, value) -> EmojiImage(key, value) }) + var index = 0 + allEmojis.addAll(labelToUrl.map { (key, value) -> EmojiImage("${index++}. $key", value) }) + lazyListState.maybeRestoreScrollPosition() } finally { refreshing = false } @@ -121,12 +129,6 @@ private fun LazyColumn( } } - val lazyListState = rememberLazyListState() - - LaunchedEffect(searchTerm) { - lazyListState.scrollToItem(0) - } - Column( width = Constraint.Fill, height = Constraint.Fill,