blob: 427031db4c2ca0e279423ffec8107390db5d5842 [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.ui.layout
import androidx.compose.runtime.Applier
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLifecycleObserver
import androidx.compose.runtime.CompositionReference
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.runtime.compositionReference
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.emit
import androidx.compose.runtime.emptyContent
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.materialize
import androidx.compose.ui.node.ExperimentalLayoutNodeApi
import androidx.compose.ui.node.LayoutEmitHelper
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.LayoutNode.LayoutState
import androidx.compose.ui.node.isAttached
import androidx.compose.ui.platform.AmbientDensity
import androidx.compose.ui.platform.AmbientLayoutDirection
import androidx.compose.ui.platform.subcomposeInto
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastForEach
/**
* Analogue of [Layout] which allows to subcompose the actual content during the measuring stage
* for example to use the values calculated during the measurement as params for the composition
* of the children.
*
* Possible use cases:
* * You need to know the constraints passed by the parent during the composition and can't solve
* your use case with just custom [Layout] or [LayoutModifier]. See [WithConstraints].
* * You want to use the size of one child during the composition of the second child.
* * You want to compose your items lazily based on the available size. For example you have a
* list of 100 items and instead of composing all of them you only compose the ones which are
* currently visible(say 5 of them) and compose next items when the component is scrolled.
*
* @sample androidx.compose.ui.samples.SubcomposeLayoutSample
*
* @param modifier [Modifier] to apply for the layout.
* @param measureBlock Measure block which provides ability to subcompose during the measuring.
*/
@Composable
@OptIn(ExperimentalLayoutNodeApi::class, ExperimentalComposeApi::class)
fun <T> SubcomposeLayout(
modifier: Modifier = Modifier,
measureBlock: SubcomposeMeasureScope<T>.(Constraints) -> MeasureResult
) {
val state = remember { SubcomposeLayoutState<T>() }
state.compositionRef = compositionReference()
val materialized = currentComposer.materialize(modifier)
emit<LayoutNode, Applier<Any>>(
ctor = LayoutEmitHelper.constructor,
update = {
set(Unit, state.setRoot)
set(materialized, LayoutEmitHelper.setModifier)
set(measureBlock, state.setMeasureBlock)
set(AmbientDensity.current, LayoutEmitHelper.setDensity)
set(AmbientLayoutDirection.current, LayoutEmitHelper.setLayoutDirection)
}
)
state.subcomposeIfRemeasureNotScheduled()
}
/**
* The receiver scope of a [SubcomposeLayout]'s measure lambda which adds ability to dynamically
* subcompose a content during the measuring on top of the features provided by [MeasureScope].
*/
interface SubcomposeMeasureScope<T> : MeasureScope {
/**
* Performs subcomposition of the provided [content] with given [slotId].
*
* @param slotId unique id which represents the slot we are composing into. If you have fixed
* amount or slots you can use enums as slot ids, or if you have a list of items maybe an
* index in the list or some other unique key can work. To be able to correctly match the
* content between remeasures you should provide the object which is equals to the one you
* used during the previous measuring.
* @param content the composable content which defines the slot. It could emit multiple
* layouts, in this case the returned list of [Measurable]s will have multiple elements.
*/
fun subcompose(slotId: T, content: @Composable () -> Unit): List<Measurable>
}
@OptIn(ExperimentalLayoutNodeApi::class)
private class SubcomposeLayoutState<T> :
SubcomposeMeasureScope<T>,
CompositionLifecycleObserver {
var compositionRef: CompositionReference? = null
// MeasureScope delegation
override var layoutDirection: LayoutDirection = LayoutDirection.Rtl
override var density: Float = 0f
override var fontScale: Float = 0f
// Pre-allocated lambdas to update LayoutNode
val setRoot: LayoutNode.(Unit) -> Unit = { root = this }
val setMeasureBlock:
LayoutNode.(SubcomposeMeasureScope<T>.(Constraints) -> MeasureResult) -> Unit =
{ measureBlocks = createMeasureBlocks(it) }
// inner state
private var root: LayoutNode? = null
private var currentIndex = 0
private val nodeToNodeState = mutableMapOf<LayoutNode, NodeState<T>>()
private val slodIdToNode = mutableMapOf<T, LayoutNode>()
override fun subcompose(slotId: T, content: @Composable () -> Unit): List<Measurable> {
val root = root!!
val layoutState = root.layoutState
check(layoutState == LayoutState.Measuring || layoutState == LayoutState.LayingOut) {
"subcompose can only be used inside the measure or layout blocks"
}
val node = slodIdToNode.getOrPut(slotId) {
LayoutNode(isVirtual = true).also {
root.insertAt(currentIndex, it)
}
}
val itemIndex = root.foldedChildren.indexOf(node)
if (itemIndex < currentIndex) {
throw IllegalArgumentException(
"$slotId was already used with subcompose during this measuring pass"
)
}
if (currentIndex != itemIndex) {
root.move(itemIndex, currentIndex, 1)
}
currentIndex++
val nodeState = nodeToNodeState.getOrPut(node) {
NodeState(slotId, emptyContent())
}
val hasPendingChanges = nodeState.composition?.hasInvalidations() ?: true
if (nodeState.content !== content || hasPendingChanges) {
nodeState.content = content
subcompose(node, nodeState)
}
return node.children
}
fun subcomposeIfRemeasureNotScheduled() {
val root = root!!
if (root.layoutState != LayoutState.NeedsRemeasure && root.isAttached()) {
root.foldedChildren.fastForEach {
subcompose(it, nodeToNodeState.getValue(it))
}
}
}
private fun subcompose(node: LayoutNode, nodeState: NodeState<T>) {
node.ignoreModelReads {
val content = nodeState.content
nodeState.composition = subcomposeInto(
container = node,
parent = compositionRef ?: error("parent composition reference not set"),
// Do not optimize this by passing nodeState.content directly; the additional
// composable function call from the lambda expression affects the scope of
// recomposition and recomposition of siblings.
composable = { content() }
)
}
}
private fun disposeAfterIndex(currentIndex: Int) {
val root = root!!
for (i in currentIndex until root.foldedChildren.size) {
val node = root.foldedChildren[i]
val nodeState = nodeToNodeState.remove(node)!!
nodeState.composition!!.dispose()
slodIdToNode.remove(nodeState.slotId)
}
root.removeAt(currentIndex, root.foldedChildren.size - currentIndex)
}
private fun createMeasureBlocks(
block: SubcomposeMeasureScope<T>.(Constraints) -> MeasureResult
): LayoutNode.MeasureBlocks = object : LayoutNode.NoIntrinsicsMeasureBlocks(
error = "Intrinsic measurements are not currently supported by SubcomposeLayout"
) {
override fun measure(
measureScope: MeasureScope,
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
this@SubcomposeLayoutState.layoutDirection = measureScope.layoutDirection
this@SubcomposeLayoutState.density = measureScope.density
this@SubcomposeLayoutState.fontScale = measureScope.fontScale
currentIndex = 0
val result = block(constraints)
val indexAfterMeasure = currentIndex
return object : MeasureResult {
override val width: Int
get() = result.width
override val height: Int
get() = result.height
override val alignmentLines: Map<AlignmentLine, Int>
get() = result.alignmentLines
override fun placeChildren() {
currentIndex = indexAfterMeasure
result.placeChildren()
disposeAfterIndex(currentIndex)
}
}
}
}
override fun onEnter() {
// do nothing
}
override fun onLeave() {
nodeToNodeState.values.forEach {
it.composition!!.dispose()
}
nodeToNodeState.clear()
slodIdToNode.clear()
}
private class NodeState<T>(
val slotId: T,
var content: @Composable () -> Unit,
var composition: Composition? = null
)
}