[go: nahoru, domu]

blob: 7c540674d161a20dc62a10dff476b508706ccdce [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.compose.Composable
import androidx.ui.core.Alignment
import androidx.compose.ui.unit.Constraints
import androidx.ui.core.ExperimentalSubcomposeLayoutApi
import androidx.ui.core.Measurable
import androidx.ui.core.MeasureScope.MeasureResult
import androidx.ui.core.Placeable
import androidx.ui.core.Remeasurement
import androidx.ui.core.RemeasurementModifier
import androidx.ui.core.SubcomposeMeasureScope
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastSumBy
import kotlin.math.abs
import kotlin.math.roundToInt
private inline class ScrollDirection(val isForward: Boolean)
@Suppress("NOTHING_TO_INLINE")
internal inline class DataIndex(val value: Int) {
inline operator fun inc(): DataIndex = DataIndex(value + 1)
inline operator fun dec(): DataIndex = DataIndex(value - 1)
inline operator fun plus(i: Int): DataIndex = DataIndex(value + i)
inline operator fun minus(i: Int): DataIndex = DataIndex(value - i)
inline operator fun minus(i: DataIndex): DataIndex = DataIndex(value - i.value)
inline operator fun compareTo(other: DataIndex): Int = value - other.value
}
@OptIn(ExperimentalSubcomposeLayoutApi::class)
internal class LazyForState<T>(val isVertical: Boolean) {
/**
* The index of the first item that is composed into the layout tree
*/
private var firstComposedItem = DataIndex(0)
/**
* The index of the last item that is composed into the layout tree
*/
private var lastComposedItem = DataIndex(-1) // obviously-bogus sentinel value
/**
* Scrolling forward is positive - i.e., the amount that the item is offset backwards
*/
private var firstItemScrollOffset = 0f
/**
* The amount of space remaining in the last item
*/
private var lastItemRemainingSpace = 0f
/**
* 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
*/
private var scrollToBeConsumed = 0f
/**
* The children that have been measured this measure pass.
* Used to avoid measuring twice in a single pass, which is illegal
*/
private val measuredThisPass: MutableMap<DataIndex, List<Placeable>> = mutableMapOf()
/**
* The listener to be passed to onScrollDeltaConsumptionRequested.
* Cached to avoid recreations
*/
val onScrollDelta: (Float) -> Float = { onScroll(it) }
/**
* The [Remeasurement] object associated with our layout. It allows us to remeasure
* synchronously during scroll.
*/
private lateinit var remeasurement: Remeasurement
/**
* The modifier which provides [remeasurement].
*/
val remeasurementModifier = object : RemeasurementModifier {
override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
this@LazyForState.remeasurement = remeasurement
}
}
private val Placeable.mainAxisSize get() = if (isVertical) height else width
private val Placeable.crossAxisSize get() = if (!isVertical) height else width
// TODO: really want an Int here
private fun onScroll(distance: Float): Float {
check(abs(scrollToBeConsumed) < 0.5f) {
"entered drag with non-zero pending scroll: $scrollToBeConsumed"
}
scrollToBeConsumed = distance
remeasurement.forceRemeasure()
val scrollConsumed = distance - scrollToBeConsumed
if (abs(scrollToBeConsumed) < 0.5) {
// 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 {
// 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 SubcomposeMeasureScope<DataIndex>.consumePendingScroll(
childConstraints: Constraints,
items: List<T>,
itemContent: @Composable (T) -> Unit
) {
val scrollDirection = ScrollDirection(isForward = scrollToBeConsumed < 0f)
while (true) {
// General outline:
// Consume as much of the drag as possible via adjusting the scroll offset
scrollToBeConsumed = consumeScrollViaOffset(scrollToBeConsumed)
// TODO: What's the correct way to handle half a pixel of unconsumed scroll?
// Allow up to half a pixel of scroll to remain unconsumed
if (abs(scrollToBeConsumed) >= 0.5f) {
// We need to bring another item onscreen. Can we?
if (!composeAndMeasureNextItem(
childConstraints,
scrollDirection,
items,
itemContent
)
) {
// Nope. Break out and return the rest of the drag
break
}
// Yay, we got another item! Our scroll offsets are populated again, go back and
// consume them in the next round.
} else {
// We've consumed the whole scroll
break
}
}
}
/**
* @return The amount of scroll remaining unconsumed
*/
private fun consumeScrollViaOffset(delta: Float): Float {
if (delta < 0) {
// Scrolling forward, content moves up
// Consume via space at end
// Remember: delta is *negative*
if (lastItemRemainingSpace >= -delta) {
// We can consume it all
updateScrollOffsets(delta)
return 0f
} else {
// All offset consumed, return the remaining offset to the caller
// delta is negative, prevRemainingSpace/lastItemRemainingSpace are positive
val prevRemainingSpace = lastItemRemainingSpace
updateScrollOffsets(-prevRemainingSpace)
return delta + prevRemainingSpace
}
} else {
// Scrolling backward, content moves down
// Consume via initial offset
if (firstItemScrollOffset >= delta) {
// We can consume it all
updateScrollOffsets(delta)
return 0f
} else {
// All offset consumed, return the remaining offset to the caller
val prevRemainingSpace = firstItemScrollOffset
updateScrollOffsets(prevRemainingSpace)
return delta - prevRemainingSpace
}
}
}
/**
* Must be called within a measure pass.
*
* @return `true` if an item was composed and measured, `false` if there are no more items in
* the scroll direction
*/
private fun SubcomposeMeasureScope<DataIndex>.composeAndMeasureNextItem(
childConstraints: Constraints,
scrollDirection: ScrollDirection,
items: List<T>,
itemContent: @Composable (T) -> Unit
): Boolean {
val nextItemIndex = if (scrollDirection.isForward) {
if (items.size > lastComposedItem.value + 1) {
++lastComposedItem
} else {
return false
}
} else {
if (firstComposedItem.value > 0) {
--firstComposedItem
} else {
return false
}
}
val nextItems = composeChildForDataIndex(nextItemIndex, items, itemContent).map {
it.measure(childConstraints, layoutDirection)
}
measuredThisPass[nextItemIndex] = nextItems
val childSize = nextItems.fastSumBy { it.mainAxisSize }
// Add in our newly composed space so that it may be consumed
if (scrollDirection.isForward) {
lastItemRemainingSpace += childSize
} else {
firstItemScrollOffset += childSize
}
return true
}
/**
* Does no bounds checking, just moves the start and last offsets in sync.
* Assumes the caller has checked bounds.
*/
private fun updateScrollOffsets(delta: Float) {
// Scrolling forward is negative delta and consumes space, so add the negative
lastItemRemainingSpace += delta
// Scrolling forward is negative delta and adds offset, so subtract the negative
firstItemScrollOffset -= delta
}
/**
* Measures and positions currently visible [items] using [itemContent] for subcomposing.
*/
fun measure(
scope: SubcomposeMeasureScope<DataIndex>,
constraints: Constraints,
items: List<T>,
itemContent: @Composable (T) -> Unit,
horizontalAlignment: Alignment.Horizontal,
verticalAlignment: Alignment.Vertical
): MeasureResult = with(scope) {
measuredThisPass.clear()
val maxMainAxis = if (isVertical) constraints.maxHeight else constraints.maxWidth
val childConstraints = Constraints(
maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity,
maxHeight = if (!isVertical) constraints.maxHeight else Constraints.Infinity
)
// We're being asked to consume scroll by the Scrollable
if (abs(scrollToBeConsumed) >= 0.5f) {
// Consume it in advance, because it simplifies the rest of this method if we
// know exactly how much scroll we've consumed - for instance, we can safely
// discard anything off the start of the viewport, because we know we can fill
// it, assuming nothing has shrunken on us (which has to be handled separately
// anyway)
consumePendingScroll(childConstraints, items, itemContent)
}
var mainAxisUsed = (-firstItemScrollOffset).roundToInt()
var maxCrossAxis = 0
// The index of the first item that should be displayed, regardless of what is
// currently displayed. Will be moved forward as we determine what's offscreen
var index = firstComposedItem
// TODO: handle the case where we can't fill the viewport due to children shrinking,
// but there are more items at the start that we could fill with
val allPlaceables = mutableListOf<Placeable>()
while (mainAxisUsed <= maxMainAxis && index.value < items.size) {
val placeables = measuredThisPass.getOrPut(index) {
composeChildForDataIndex(index, items, itemContent).fastMap {
it.measure(childConstraints)
}
}
var size = 0
placeables.fastForEach {
size += it.mainAxisSize
maxCrossAxis = maxOf(maxCrossAxis, it.crossAxisSize)
}
mainAxisUsed += size
if (mainAxisUsed < 0f) {
// this item is offscreen, remove it and the offset it took up
firstComposedItem = index + 1
firstItemScrollOffset -= size
} else {
allPlaceables.addAll(placeables)
}
index++
}
lastComposedItem = index - 1 // index is incremented after the last iteration
lastItemRemainingSpace = if (mainAxisUsed > maxMainAxis) {
(mainAxisUsed - maxMainAxis).toFloat()
} else {
0f
}
// Wrap the content of the children
val layoutWidth = constraints.constrainWidth(
if (isVertical) maxCrossAxis else mainAxisUsed
)
val layoutHeight = constraints.constrainHeight(
if (!isVertical) maxCrossAxis else mainAxisUsed
)
return layout(layoutWidth, layoutHeight) {
var currentMainAxis = (-firstItemScrollOffset).roundToInt()
allPlaceables.fastForEach {
if (isVertical) {
val x = horizontalAlignment.align(layoutWidth - it.width, layoutDirection)
if (currentMainAxis + it.height > 0 && currentMainAxis < layoutHeight) {
it.placeAbsolute(x, currentMainAxis)
}
currentMainAxis += it.height
} else {
val y = verticalAlignment.align(layoutHeight - it.height)
if (currentMainAxis + it.width > 0 && currentMainAxis < layoutWidth) {
it.place(currentMainAxis, y)
}
currentMainAxis += it.width
}
}
}
}
private fun SubcomposeMeasureScope<DataIndex>.composeChildForDataIndex(
dataIndex: DataIndex,
items: List<T>,
itemContent: @Composable (T) -> Unit
): List<Measurable> {
val item = items[dataIndex.value]
return subcompose(dataIndex) {
itemContent(item)
}
}
}