| /* |
| * Copyright 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation.lazy |
| |
| import androidx.compose.foundation.MutatePriority |
| import androidx.compose.foundation.gestures.ScrollScope |
| import androidx.compose.foundation.gestures.ScrollableState |
| import androidx.compose.foundation.interaction.InteractionSource |
| import androidx.compose.foundation.interaction.MutableInteractionSource |
| import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchPolicy |
| import androidx.compose.foundation.lazy.list.DataIndex |
| import androidx.compose.foundation.lazy.list.LazyListItemPlacementAnimator |
| import androidx.compose.foundation.lazy.list.LazyListItemsProvider |
| import androidx.compose.foundation.lazy.list.LazyListMeasureResult |
| import androidx.compose.foundation.lazy.list.LazyListScrollPosition |
| import androidx.compose.foundation.lazy.list.doSmoothScrollToItem |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.Stable |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.saveable.Saver |
| import androidx.compose.runtime.saveable.listSaver |
| import androidx.compose.runtime.saveable.rememberSaveable |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.layout.Remeasurement |
| import androidx.compose.ui.layout.RemeasurementModifier |
| import androidx.compose.ui.unit.Constraints |
| import androidx.compose.ui.unit.Density |
| import kotlin.math.abs |
| |
| /** |
| * Creates a [LazyListState] that is remembered across compositions. |
| * |
| * Changes to the provided initial values will **not** result in the state being recreated or |
| * changed in any way if it has already been created. |
| * |
| * @param initialFirstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex] |
| * @param initialFirstVisibleItemScrollOffset the initial value for |
| * [LazyListState.firstVisibleItemScrollOffset] |
| */ |
| @Composable |
| fun rememberLazyListState( |
| initialFirstVisibleItemIndex: Int = 0, |
| initialFirstVisibleItemScrollOffset: Int = 0 |
| ): LazyListState { |
| return rememberSaveable(saver = LazyListState.Saver) { |
| LazyListState( |
| initialFirstVisibleItemIndex, |
| initialFirstVisibleItemScrollOffset |
| ) |
| } |
| } |
| |
| /** |
| * A state object that can be hoisted to control and observe scrolling. |
| * |
| * In most cases, this will be created via [rememberLazyListState]. |
| * |
| * @param firstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex] |
| * @param firstVisibleItemScrollOffset the initial value for |
| * [LazyListState.firstVisibleItemScrollOffset] |
| */ |
| @Stable |
| class LazyListState constructor( |
| firstVisibleItemIndex: Int = 0, |
| firstVisibleItemScrollOffset: Int = 0 |
| ) : ScrollableState { |
| /** |
| * The holder class for the current scroll position. |
| */ |
| private val scrollPosition = |
| LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset) |
| |
| /** |
| * The index of the first item that is visible |
| */ |
| val firstVisibleItemIndex: Int get() = scrollPosition.observableIndex |
| |
| /** |
| * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the |
| * amount that the item is offset backwards |
| */ |
| val firstVisibleItemScrollOffset: Int get() = scrollPosition.observableScrollOffset |
| |
| /** Backing state for [layoutInfo] */ |
| private val layoutInfoState = mutableStateOf<LazyListLayoutInfo>(EmptyLazyListLayoutInfo) |
| |
| /** |
| * The object of [LazyListLayoutInfo] calculated during the last layout pass. For example, |
| * you can use it to calculate what items are currently visible. |
| */ |
| val layoutInfo: LazyListLayoutInfo get() = layoutInfoState.value |
| |
| /** |
| * [InteractionSource] that will be used to dispatch drag events when this |
| * list is being dragged. If you want to know whether the fling (or animated scroll) is in |
| * progress, use [isScrollInProgress]. |
| */ |
| val interactionSource: InteractionSource get() = internalInteractionSource |
| |
| internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource() |
| |
| /** |
| * The amount of scroll to be consumed in the next layout pass. Scrolling forward is negative |
| * - that is, it is the amount that the items are offset in y |
| */ |
| internal var scrollToBeConsumed = 0f |
| private set |
| |
| /** |
| * The same as [firstVisibleItemIndex] but the read will not trigger remeasure. |
| */ |
| internal val firstVisibleItemIndexNonObservable: DataIndex get() = scrollPosition.index |
| |
| /** |
| * The same as [firstVisibleItemScrollOffset] but the read will not trigger remeasure. |
| */ |
| internal val firstVisibleItemScrollOffsetNonObservable: Int get() = scrollPosition.scrollOffset |
| |
| /** |
| * Non-observable property with the count of items being visible during the last measure pass. |
| */ |
| internal var visibleItemsCount = 0 |
| |
| /** |
| * Needed for [animateScrollToItem]. Updated on every measure. |
| */ |
| internal var density: Density = Density(1f, 1f) |
| |
| /** |
| * The ScrollableController instance. We keep it as we need to call stopAnimation on it once |
| * we reached the end of the list. |
| */ |
| private val scrollableState = ScrollableState { -onScroll(-it) } |
| |
| /** |
| * Only used for testing to confirm that we're not making too many measure passes |
| */ |
| /*@VisibleForTesting*/ |
| internal var numMeasurePasses: Int = 0 |
| private set |
| |
| /** |
| * Only used for testing to disable prefetching when needed to test the main logic. |
| */ |
| /*@VisibleForTesting*/ |
| internal var prefetchingEnabled: Boolean = true |
| |
| /** |
| * The index scheduled to be prefetched (or the last prefetched index if the prefetch is done). |
| */ |
| private var indexToPrefetch = -1 |
| |
| /** |
| * Keeps the scrolling direction during the previous calculation in order to be able to |
| * detect the scrolling direction change. |
| */ |
| private var wasScrollingForward = false |
| |
| /** |
| * The [Remeasurement] object associated with our layout. It allows us to remeasure |
| * synchronously during scroll. |
| */ |
| private var remeasurement: Remeasurement? = null |
| |
| /** |
| * The modifier which provides [remeasurement]. |
| */ |
| internal val remeasurementModifier = object : RemeasurementModifier { |
| override fun onRemeasurementAvailable(remeasurement: Remeasurement) { |
| this@LazyListState.remeasurement = remeasurement |
| } |
| } |
| |
| internal var placementAnimator by mutableStateOf<LazyListItemPlacementAnimator?>(null) |
| |
| /** |
| * Constraints passed to the prefetcher for premeasuring the prefetched items. |
| */ |
| internal var premeasureConstraints = Constraints() |
| |
| /** |
| * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset] |
| * pixels. |
| * |
| * @param index the index to which to scroll. Must be non-negative. |
| * @param scrollOffset the offset that the item should end up after the scroll. Note that |
| * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will |
| * scroll the item further upward (taking it partly offscreen). |
| */ |
| suspend fun scrollToItem( |
| /*@IntRange(from = 0)*/ |
| index: Int, |
| scrollOffset: Int = 0 |
| ) { |
| return scrollableState.scroll { |
| snapToItemIndexInternal(index, scrollOffset) |
| } |
| } |
| |
| internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) { |
| scrollPosition.requestPosition(DataIndex(index), scrollOffset) |
| // placement animation is not needed because we snap into a new position. |
| placementAnimator?.reset() |
| remeasurement?.forceRemeasure() |
| } |
| |
| /** |
| * Call this function to take control of scrolling and gain the ability to send scroll events |
| * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be |
| * performed within a [scroll] block (even if they don't call any other methods on this |
| * object) in order to guarantee that mutual exclusion is enforced. |
| * |
| * If [scroll] is called from elsewhere, this will be canceled. |
| */ |
| override suspend fun scroll( |
| scrollPriority: MutatePriority, |
| block: suspend ScrollScope.() -> Unit |
| ): Unit = scrollableState.scroll(scrollPriority, block) |
| |
| override fun dispatchRawDelta(delta: Float): Float = |
| scrollableState.dispatchRawDelta(delta) |
| |
| override val isScrollInProgress: Boolean |
| get() = scrollableState.isScrollInProgress |
| |
| private var canScrollBackward: Boolean = false |
| internal var canScrollForward: Boolean = false |
| private set |
| |
| // TODO: Coroutine scrolling APIs will allow this to be private again once we have more |
| // fine-grained control over scrolling |
| /*@VisibleForTesting*/ |
| internal fun onScroll(distance: Float): Float { |
| if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) { |
| return 0f |
| } |
| check(abs(scrollToBeConsumed) <= 0.5f) { |
| "entered drag with non-zero pending scroll: $scrollToBeConsumed" |
| } |
| scrollToBeConsumed += distance |
| |
| // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation |
| // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if |
| // we have less than 0.5 pixels |
| if (abs(scrollToBeConsumed) > 0.5f) { |
| val preScrollToBeConsumed = scrollToBeConsumed |
| remeasurement?.forceRemeasure() |
| if (prefetchingEnabled && prefetchPolicy != null) { |
| notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed) |
| } |
| } |
| |
| // here scrollToBeConsumed is already consumed during the forceRemeasure invocation |
| if (abs(scrollToBeConsumed) <= 0.5f) { |
| // We consumed all of it - we'll hold onto the fractional scroll for later, so report |
| // that we consumed the whole thing |
| return distance |
| } else { |
| val scrollConsumed = distance - scrollToBeConsumed |
| // We did not consume all of it - return the rest to be consumed elsewhere (e.g., |
| // nested scrolling) |
| scrollToBeConsumed = 0f // We're not consuming the rest, give it back |
| return scrollConsumed |
| } |
| } |
| |
| private fun notifyPrefetch(delta: Float) { |
| if (!prefetchingEnabled) { |
| return |
| } |
| val info = layoutInfo |
| if (info.visibleItemsInfo.isNotEmpty()) { |
| // check(isActive) |
| val scrollingForward = delta < 0 |
| val indexToPrefetch = if (scrollingForward) { |
| info.visibleItemsInfo.last().index + 1 |
| } else { |
| info.visibleItemsInfo.first().index - 1 |
| } |
| if (indexToPrefetch != this.indexToPrefetch && |
| indexToPrefetch in 0 until info.totalItemsCount |
| ) { |
| if (wasScrollingForward != scrollingForward) { |
| // the scrolling direction has been changed which means the last prefetched |
| // is not going to be reached anytime soon so it is safer to dispose it. |
| // if this item is already visible it is safe to call the method anyway |
| // as it will be no-op |
| prefetchPolicy?.cancelScheduledPrefetch() |
| } |
| this.wasScrollingForward = scrollingForward |
| this.indexToPrefetch = indexToPrefetch |
| prefetchPolicy?.scheduleForPrefetch( |
| listOf(indexToPrefetch to premeasureConstraints) |
| ) |
| } |
| } |
| } |
| |
| internal var prefetchPolicy: LazyLayoutPrefetchPolicy? = null |
| |
| /** |
| * Animate (smooth scroll) to the given item. |
| * |
| * @param index the index to which to scroll. Must be non-negative. |
| * @param scrollOffset the offset that the item should end up after the scroll. Note that |
| * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will |
| * scroll the item further upward (taking it partly offscreen) |
| */ |
| suspend fun animateScrollToItem( |
| /*@IntRange(from = 0)*/ |
| index: Int, |
| scrollOffset: Int = 0 |
| ) { |
| doSmoothScrollToItem(index, scrollOffset) |
| } |
| |
| /** |
| * Updates the state with the new calculated scroll position and consumed scroll. |
| */ |
| internal fun applyMeasureResult(result: LazyListMeasureResult) { |
| visibleItemsCount = result.visibleItemsInfo.size |
| scrollPosition.updateFromMeasureResult(result) |
| scrollToBeConsumed -= result.consumedScroll |
| layoutInfoState.value = result |
| |
| canScrollForward = result.canScrollForward |
| canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 || |
| result.firstVisibleItemScrollOffset != 0 |
| |
| numMeasurePasses++ |
| } |
| |
| /** |
| * When the user provided custom keys for the items we can try to detect when there were |
| * items added or removed before our current first visible item and keep this item |
| * as the first visible one even given that its index has been changed. |
| */ |
| internal fun updateScrollPositionIfTheFirstItemWasMoved(itemsProvider: LazyListItemsProvider) { |
| scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemsProvider) |
| } |
| |
| companion object { |
| /** |
| * The default [Saver] implementation for [LazyListState]. |
| */ |
| val Saver: Saver<LazyListState, *> = listSaver( |
| save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) }, |
| restore = { |
| LazyListState( |
| firstVisibleItemIndex = it[0], |
| firstVisibleItemScrollOffset = it[1] |
| ) |
| } |
| ) |
| } |
| } |
| |
| private object EmptyLazyListLayoutInfo : LazyListLayoutInfo { |
| override val visibleItemsInfo = emptyList<LazyListItemInfo>() |
| override val viewportStartOffset = 0 |
| override val viewportEndOffset = 0 |
| override val totalItemsCount = 0 |
| } |