blob: 2c0b80009063da05a2222b5c26060c0b0f282a34 [file] [log] [blame]
/*
* 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.annotation.IntRange as AndroidXIntRange
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.copy
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.Orientation
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.AwaitFirstLayoutModifier
import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
import androidx.compose.foundation.lazy.layout.animateScrollToItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
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.runtime.snapshots.Snapshot
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.MeasureResult
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 androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.ranges.IntRange
import kotlin.ranges.until
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* 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]
*/
@OptIn(ExperimentalFoundationApi::class)
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
internal var hasLookaheadPassOccurred: Boolean = false
private set
internal var postLookaheadLayoutInfo: LazyListMeasureResult? = null
private set
/**
* The holder class for the current scroll position.
*/
private val scrollPosition =
LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
private val animateScrollScope = LazyListAnimateScrollScope(this)
/**
* The index of the first item that is visible.
*
* Note that this property is observable and if you use it in the composable function it will
* be recomposed on every change causing potential performance issues.
*
* If you want to run some side effects like sending an analytics event or updating a state
* based on this value consider using "snapshotFlow":
* @sample androidx.compose.foundation.samples.UsingListScrollPositionForSideEffectSample
*
* If you need to use it in the composition then consider wrapping the calculation into a
* derived state in order to only have recompositions when the derived value changes:
* @sample androidx.compose.foundation.samples.UsingListScrollPositionInCompositionSample
*/
val firstVisibleItemIndex: Int get() = scrollPosition.index
/**
* The scroll offset of the first visible item. Scrolling forward is positive - i.e., the
* amount that the item is offset backwards.
*
* Note that this property is observable and if you use it in the composable function it will
* be recomposed on every scroll causing potential performance issues.
* @see firstVisibleItemIndex for samples with the recommended usage patterns.
*/
val firstVisibleItemScrollOffset: Int get() = scrollPosition.scrollOffset
/** Backing state for [layoutInfo] */
private val layoutInfoState = mutableStateOf(
EmptyLazyListMeasureResult,
neverEqualPolicy()
)
/**
* The object of [LazyListLayoutInfo] calculated during the last layout pass. For example,
* you can use it to calculate what items are currently visible.
*
* Note that this property is observable and is updated after every scroll or remeasure.
* If you use it in the composable function it will be recomposed on every change causing
* potential performance issues including infinity recomposition loop.
* Therefore, avoid using it in the composition.
*
* If you want to run some side effects like sending an analytics event or updating a state
* based on this value consider using "snapshotFlow":
* @sample androidx.compose.foundation.samples.UsingListLayoutInfoForSideEffectSample
*/
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
/**
* 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
/**
* The handle associated with the current index from [indexToPrefetch].
*/
private var currentPrefetchHandle: LazyLayoutPrefetchState.PrefetchHandle? = null
/**
* 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.
*/
internal var remeasurement: Remeasurement? = null
private set
/**
* The modifier which provides [remeasurement].
*/
internal val remeasurementModifier = object : RemeasurementModifier {
override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
this@LazyListState.remeasurement = remeasurement
}
}
/**
* Provides a modifier which allows to delay some interactions (e.g. scroll)
* until layout is ready.
*/
internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
internal val itemAnimator = LazyListItemAnimator()
internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
/**
* Constraints passed to the prefetcher for premeasuring the prefetched items.
*/
internal var premeasureConstraints = Constraints()
/**
* Stores currently pinned items which are always composed.
*/
internal val pinnedItems = LazyLayoutPinnedItemList()
internal val nearestRange: IntRange by scrollPosition.nearestRangeState
/**
* 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(
@AndroidXIntRange(from = 0)
index: Int,
scrollOffset: Int = 0
) {
scroll {
snapToItemIndexInternal(index, scrollOffset)
}
}
internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
scrollPosition.requestPosition(index, scrollOffset)
// placement animation is not needed because we snap into a new position.
itemAnimator.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
) {
awaitLayoutModifier.waitForFirstLayout()
scrollableState.scroll(scrollPriority, block)
}
override fun dispatchRawDelta(delta: Float): Float =
scrollableState.dispatchRawDelta(delta)
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
override var canScrollForward: Boolean by mutableStateOf(false)
private set
override var canScrollBackward: Boolean by mutableStateOf(false)
private set
internal val placementScopeInvalidator = ObservableScopeInvalidator()
// 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 layoutInfo = layoutInfoState.value
val preScrollToBeConsumed = scrollToBeConsumed
val intDelta = scrollToBeConsumed.roundToInt()
val postLookaheadInfo = postLookaheadLayoutInfo
var scrolledWithoutRemeasure = layoutInfo.tryToApplyScrollWithoutRemeasure(
delta = intDelta,
updateAnimations = !hasLookaheadPassOccurred
)
if (scrolledWithoutRemeasure && postLookaheadInfo != null) {
scrolledWithoutRemeasure = postLookaheadInfo.tryToApplyScrollWithoutRemeasure(
delta = intDelta,
updateAnimations = true
)
}
if (scrolledWithoutRemeasure) {
applyMeasureResult(
result = layoutInfo,
isLookingAhead = hasLookaheadPassOccurred,
visibleItemsStayedTheSame = true
)
// we don't need to remeasure, so we only trigger re-placement:
placementScopeInvalidator.invalidateScope()
notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed, layoutInfo)
} else {
remeasurement?.forceRemeasure()
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, layoutInfo: LazyListLayoutInfo = this.layoutInfo) {
if (!prefetchingEnabled) {
return
}
val info = layoutInfo
if (info.visibleItemsInfo.isNotEmpty()) {
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
currentPrefetchHandle?.cancel()
}
this.wasScrollingForward = scrollingForward
this.indexToPrefetch = indexToPrefetch
currentPrefetchHandle = prefetchState.schedulePrefetch(
indexToPrefetch, premeasureConstraints
)
}
}
}
private fun cancelPrefetchIfVisibleItemsChanged(info: LazyListLayoutInfo) {
if (indexToPrefetch != -1 && info.visibleItemsInfo.isNotEmpty()) {
val expectedPrefetchIndex = if (wasScrollingForward) {
info.visibleItemsInfo.last().index + 1
} else {
info.visibleItemsInfo.first().index - 1
}
if (indexToPrefetch != expectedPrefetchIndex) {
indexToPrefetch = -1
currentPrefetchHandle?.cancel()
currentPrefetchHandle = null
}
}
}
internal val prefetchState = LazyLayoutPrefetchState()
/**
* 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(
@AndroidXIntRange(from = 0)
index: Int,
scrollOffset: Int = 0
) {
animateScrollScope.animateScrollToItem(
index,
scrollOffset,
NumberOfItemsToTeleport,
density
)
}
/**
* Updates the state with the new calculated scroll position and consumed scroll.
*/
internal fun applyMeasureResult(
result: LazyListMeasureResult,
isLookingAhead: Boolean,
visibleItemsStayedTheSame: Boolean = false
) {
if (!isLookingAhead && hasLookaheadPassOccurred) {
// If there was already a lookahead pass, record this result as postLookahead result
postLookaheadLayoutInfo = result
} else {
if (isLookingAhead) {
hasLookaheadPassOccurred = true
}
if (visibleItemsStayedTheSame) {
scrollPosition.updateScrollOffset(result.firstVisibleItemScrollOffset)
} else {
scrollPosition.updateFromMeasureResult(result)
cancelPrefetchIfVisibleItemsChanged(result)
}
canScrollBackward = result.canScrollBackward
canScrollForward = result.canScrollForward
scrollToBeConsumed -= result.consumedScroll
layoutInfoState.value = result
if (isLookingAhead) updateScrollDeltaForPostLookahead(result.scrollBackAmount)
numMeasurePasses++
}
}
internal var coroutineScope: CoroutineScope? = null
internal val scrollDeltaBetweenPasses: Float
get() = _scrollDeltaBetweenPasses.value
private var _scrollDeltaBetweenPasses: AnimationState<Float, AnimationVector1D> =
AnimationState(Float.VectorConverter, 0f, 0f)
// Updates the scroll delta between lookahead & post-lookahead pass
private fun updateScrollDeltaForPostLookahead(delta: Float) {
if (delta <= with(density) { DeltaThresholdForScrollAnimation.toPx() }) {
// If the delta is within the threshold, scroll by the delta amount instead of animating
return
}
// Scroll delta is updated during lookahead, we don't need to trigger lookahead when
// the delta changes.
Snapshot.withoutReadObservation {
val currentDelta = _scrollDeltaBetweenPasses.value
if (_scrollDeltaBetweenPasses.isRunning) {
_scrollDeltaBetweenPasses = _scrollDeltaBetweenPasses.copy(currentDelta - delta)
coroutineScope?.launch {
_scrollDeltaBetweenPasses.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
true
)
}
} else {
_scrollDeltaBetweenPasses = AnimationState(Float.VectorConverter, -delta)
coroutineScope?.launch {
_scrollDeltaBetweenPasses.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
true
)
}
}
}
}
/**
* 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(
itemProvider: LazyListItemProvider,
firstItemIndex: Int
): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
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 val DeltaThresholdForScrollAnimation = 1.dp
private val EmptyLazyListMeasureResult = LazyListMeasureResult(
firstVisibleItem = null,
firstVisibleItemScrollOffset = 0,
canScrollForward = false,
consumedScroll = 0f,
measureResult = object : MeasureResult {
override val width: Int = 0
override val height: Int = 0
@Suppress("PrimitiveInCollection")
override val alignmentLines: Map<AlignmentLine, Int> = emptyMap()
override fun placeChildren() {}
},
scrollBackAmount = 0f,
visibleItemsInfo = emptyList(),
viewportStartOffset = 0,
viewportEndOffset = 0,
totalItemsCount = 0,
reverseLayout = false,
orientation = Orientation.Vertical,
afterContentPadding = 0,
mainAxisItemSpacing = 0,
remeasureNeeded = false
)
private const val NumberOfItemsToTeleport = 100