blob: 72bca3db138f29f1dc012889e371b91b5c4b43cc [file] [log] [blame]
/*
* Copyright 2019 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.ui.foundation
import android.content.Context
import androidx.compose.Composable
import androidx.compose.Compose
import androidx.compose.CompositionReference
import androidx.compose.FrameManager
import androidx.compose.compositionReference
import androidx.compose.remember
import androidx.ui.core.Clip
import androidx.ui.core.Constraints
import androidx.ui.core.ContextAmbient
import androidx.ui.core.LayoutNode
import androidx.ui.core.Measurable
import androidx.ui.core.MeasureScope
import androidx.ui.core.MeasuringIntrinsicsMeasureBlocks
import androidx.ui.core.Modifier
import androidx.ui.core.Ref
import androidx.ui.foundation.gestures.DragDirection
import androidx.ui.foundation.gestures.Scrollable
import androidx.ui.foundation.gestures.ScrollableState
import androidx.ui.foundation.shape.RectangleShape
import androidx.ui.unit.IntPx
import androidx.ui.unit.ipx
import androidx.ui.unit.px
import androidx.ui.unit.round
import kotlin.math.abs
import kotlin.math.round
private inline class ScrollDirection(val isForward: Boolean)
@Suppress("NOTHING_TO_INLINE")
private 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
}
private inline class LayoutIndex(val value: Int)
private class ListState<T>(
var itemCallback: @Composable() (T) -> Unit,
var data: List<T>
) {
var forceRecompose = false
var compositionRef: CompositionReference? = null
/**
* Should always be non-null when attached
*/
var context: Context? = null
/**
* Used to get the reference to populate [rootNode]
*/
val rootNodeRef = Ref<LayoutNode>()
/**
* The root [LayoutNode] of this [AdapterList]
*/
val rootNode get() = rootNodeRef.value!!
/**
* The measure blocks for [rootNode]
*/
val measureBlocks = ListMeasureBlocks()
/**
* The index of the first item that is composed into the layout tree
*/
var firstComposedItem = DataIndex(0)
/**
* The index of the last item that is composed into the layout tree
*/
var lastComposedItem = DataIndex(-1) // obviously-bogus sentinel value
/**
* Scrolling forward is positive - i.e., the amount that the item is offset backwards
*/
var firstItemScrollOffset = 0f
/**
* The amount of space remaining in the last item
*/
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
*/
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, Boolean> = mutableMapOf()
/**
* The listener to be passed to onScrollDeltaConsumptionRequested.
* Cached to avoid recreations
*/
val onScrollDeltaConsumptionRequestedListener: (Float) -> Float = { onScroll(it) }
// 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
rootNode.requestRemeasure()
rootNode.owner!!.measureAndLayout()
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 consumePendingScroll() {
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(scrollDirection)) {
// 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 composeAndMeasureNextItem(scrollDirection: ScrollDirection): Boolean {
val nextItemIndex = if (scrollDirection.isForward) {
if (data.size > lastComposedItem.value + 1) {
lastComposedItem + 1
} else {
return false
}
} else {
if (firstComposedItem.value > 0) {
firstComposedItem - 1
} else {
return false
}
}
val nextItem = composeChildForDataIndex(nextItemIndex)
// TODO: axis
val childConstraints = Constraints(maxWidth = rootNode.width, maxHeight = IntPx.Infinity)
val childPlaceable = nextItem.measure(childConstraints)
measuredThisPass[nextItemIndex] = true
val childHeight = childPlaceable.height
// Add in our newly composed space so that it may be consumed
if (scrollDirection.isForward) {
lastItemRemainingSpace += childHeight.value
} else {
firstItemScrollOffset += childHeight.value
}
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
rootNode.requestRemeasure()
}
private inner class ListMeasureBlocks : LayoutNode.NoIntrinsicsMeasureBlocks(
error = "Intrinsic measurements are not supported by AdapterList (yet)"
) {
override fun measure(
measureScope: MeasureScope,
measurables: List<Measurable>,
constraints: Constraints
): MeasureScope.LayoutResult {
measuredThisPass.clear()
if (forceRecompose) {
rootNode.ignoreModelReads { recomposeAllChildren() }
// if there were models created and read inside this subcomposition
// and we are going to modify these models within the same frame,
// the composables which read this model will not be recomposed.
// to make this possible we should switch to the next frame.
FrameManager.nextFrame()
}
// 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()
}
val width = constraints.maxWidth.value
val height = constraints.maxHeight.value
// TODO: axis
val childConstraints = Constraints(maxWidth = width.ipx, maxHeight = IntPx.Infinity)
var heightUsed = round(-firstItemScrollOffset)
// 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 itemIndexOffset = 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
var index = itemIndexOffset
while (heightUsed < height && index.value < data.size) {
val node = getNodeForDataIndex(index)
if (measuredThisPass[index] != true) {
node.measure(childConstraints)
measuredThisPass[index] = true
}
val childHeight = node.height.value
heightUsed += childHeight
if (heightUsed < 0f) {
// this item is offscreen, remove it and the offset it took up
itemIndexOffset = index + 1
firstItemScrollOffset -= childHeight
}
index++
}
lastComposedItem = index - 1 // index is incremented after the last iteration
// The number of layout children that we want to have, not including any offscreen
// items at the start or end
val numDesiredChildren = (lastComposedItem - itemIndexOffset + 1).value
// Remove no-longer-needed items from the start of the list
if (itemIndexOffset > firstComposedItem) {
rootNode.emitRemoveAt(0, (itemIndexOffset - firstComposedItem).value)
}
firstComposedItem = itemIndexOffset
lastItemRemainingSpace = if (heightUsed > height) {
(heightUsed - height)
} else {
0f
}
// Remove no-longer-needed items from the end of the list
val layoutChildrenInNode = rootNode.layoutChildren.size
if (layoutChildrenInNode > numDesiredChildren) {
rootNode.emitRemoveAt(
// We've already removed the extras at the start, so the desired children
// start at index 0
index = numDesiredChildren,
count = layoutChildrenInNode - numDesiredChildren
)
}
return measureScope.layout(width = width.ipx, height = height.ipx) {
var currentY = round(-firstItemScrollOffset)
rootNode.layoutChildren.forEach {
it.place(x = IntPx.Zero, y = currentY.px.round())
currentY += it.height.value
}
}
}
}
fun recomposeIfAttached() {
if (rootNode.owner != null) {
// TODO: run this in an `onPreCommit` callback for multithreaded/deferred composition
// safety
recomposeAllChildren()
}
}
private fun recomposeAllChildren() {
for (idx in rootNode.layoutChildren.indices) {
composeChildForDataIndex(LayoutIndex(idx).toDataIndex())
}
forceRecompose = false
}
private fun getNodeForDataIndex(dataIndex: DataIndex): LayoutNode {
val layoutIndex = dataIndex.toLayoutIndex()
val layoutChildren = rootNode.layoutChildren
val numLayoutChildren = layoutChildren.size
check(layoutIndex.value <= numLayoutChildren) {
"Index too high: $dataIndex, firstComposedItem: $firstComposedItem, " +
"layout index: $layoutIndex, current children: $numLayoutChildren"
}
return if (layoutIndex.value < numLayoutChildren) {
layoutChildren[layoutIndex.value]
} else {
composeChildForDataIndex(dataIndex)
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DataIndex.toLayoutIndex(): LayoutIndex {
return LayoutIndex(value - firstComposedItem.value)
}
@Suppress("NOTHING_TO_INLINE")
private inline fun LayoutIndex.toDataIndex(): DataIndex {
return DataIndex(value + firstComposedItem.value)
}
/**
* Contract: this must be either a data index that is already in the LayoutNode, or one
* immediately on either side of those.
*/
private fun composeChildForDataIndex(dataIndex: DataIndex): LayoutNode {
val layoutIndex = dataIndex.toLayoutIndex()
check(layoutIndex.value >= -1 && layoutIndex.value <= rootNode.layoutChildren.size) {
"composeChildForDataIndex called with invalid index, data index: $dataIndex," +
" layout index: $layoutIndex"
}
val node: LayoutNode
val atStart = layoutIndex.value == -1
val atEnd = rootNode.layoutChildren.size == layoutIndex.value
if (atEnd || atStart) {
// This is a new node, either at the end or the start
node = LayoutNode()
node.measureBlocks = MeasuringIntrinsicsMeasureBlocks { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(
Constraints(
minWidth = constraints.minWidth,
maxWidth = constraints.maxWidth
)
)
}
val columnWidth = (placeables.maxBy { it.width.value }?.width ?: 0.ipx)
.coerceAtLeast(constraints.minWidth)
val columnHeight = placeables.sumBy { it.height.value }.ipx.coerceIn(
constraints.minHeight,
constraints.maxHeight
)
layout(columnWidth, columnHeight) {
var top = 0.ipx
placeables.forEach { placeable ->
placeable.place(0.ipx, top)
top += placeable.height
}
}
}
// If it's at the end, then the value is already correct, because we don't need to
// move any existing LayoutNodes.
// If it's at the beginning, it has a layout index of -1 because it's being inserted
// _before_ index 0, but this means that we need to insert it at index 0 and then
// the others will be shifted forward. This accounts for these different methods of
// tracking.
val newLayoutIndex = if (atStart) 0 else layoutIndex.value
rootNode.emitInsertAt(newLayoutIndex, node)
if (atEnd) {
lastComposedItem++
} else {
// atStart
firstComposedItem--
}
} else {
node = rootNode.layoutChildren[layoutIndex.value]
}
Compose.subcomposeInto(node, context!!, compositionRef) {
itemCallback(data[dataIndex.value])
}
return node
}
}
/**
* A vertically scrolling list that only composes and lays out the currently visible items.
*
* @param data the backing list of data to display
* @param modifier the modifier to apply to this `AdapterList`
* @param itemCallback a callback that takes an item from [data] and emits the UI for that item.
* May emit any number of components, which will be stacked vertically
*/
@Composable
fun <T> AdapterList(
data: List<T>,
modifier: Modifier = Modifier.None,
itemCallback: @Composable() (T) -> Unit
) {
val state = remember { ListState(data = data, itemCallback = itemCallback) }
state.itemCallback = itemCallback
state.data = data
state.context = ContextAmbient.current
state.compositionRef = compositionReference()
state.forceRecompose = true
Scrollable(dragDirection = DragDirection.Vertical, scrollableState = ScrollableState(
onScrollDeltaConsumptionRequested = state.onScrollDeltaConsumptionRequestedListener
)) {
Clip(shape = RectangleShape) {
androidx.ui.core.LayoutNode(
modifier = modifier,
ref = state.rootNodeRef,
measureBlocks = state.measureBlocks
)
}
}
state.recomposeIfAttached()
}