Skip to content

Commit

Permalink
Partially implement Compose UI Box stretch support. (#1854)
Browse files Browse the repository at this point in the history
* Add initial implementation.

* More changes.

* WIP

* Get partially working.

* Add temp disable.
  • Loading branch information
colinrtwhite authored Mar 8, 2024
1 parent eada34e commit aa95a2e
Show file tree
Hide file tree
Showing 18 changed files with 325 additions and 48 deletions.
11 changes: 11 additions & 0 deletions redwood-layout-composeui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ android {
androidResources = true
}
}

afterEvaluate {
spotless {
kotlin {
targetExclude(
// Apache 2-licensed file from AOSP.
"src/commonMain/kotlin/app/cash/redwood/layout/composeui/Box.kt",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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 app.cash.redwood.layout.composeui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastForEachIndexed
import kotlin.math.max

/**
* A custom [androidx.compose.foundation.layout.Box] implementation that:
*
* - Supports passing child layout info as part of the Box's constructor instead of reading the
* information from the child's modifier.
* - Supports stretching children along each axis individually.
*/
@Composable
internal inline fun Box(
childrenLayoutInfo: BoxChildrenLayoutInfo,
modifier: Modifier = Modifier,
propagateMinConstraints: Boolean = false,
content: @Composable () -> Unit,
) {
val measurePolicy = remember(childrenLayoutInfo, propagateMinConstraints) {
BoxMeasurePolicy(childrenLayoutInfo.infos, propagateMinConstraints)
}
Layout(
content = content,
measurePolicy = measurePolicy,
modifier = modifier,
)
}

/** Wrapper class to ensure argument stability when passed to a Compose function. */
@Immutable
internal data class BoxChildrenLayoutInfo(
val infos: List<BoxChildLayoutInfo>,
)

@Immutable
internal data class BoxChildLayoutInfo(
val alignment: Alignment,
val matchParentWidth: Boolean,
val matchParentHeight: Boolean,
)

@PublishedApi
internal data class BoxMeasurePolicy(
private val childrenLayoutInfo: List<BoxChildLayoutInfo>,
private val propagateMinConstraints: Boolean,
) : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints,
): MeasureResult {
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight,
) {}
}

val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}

if (measurables.size == 1) {
val measurable = measurables[0]
val layoutInfo = childrenLayoutInfo[0]
var childConstraints = contentConstraints
if (layoutInfo.matchParentWidth) {
childConstraints = childConstraints.copy(
minWidth = constraints.minWidth,
maxWidth = constraints.minWidth,
)
}
if (layoutInfo.matchParentHeight) {
childConstraints = childConstraints.copy(
minHeight = constraints.minHeight,
maxHeight = constraints.minHeight,
)
}
val placeable = measurable.measure(childConstraints)
val boxWidth = max(constraints.minWidth, placeable.width)
val boxHeight = max(constraints.minHeight, placeable.height)

return layout(boxWidth, boxHeight) {
placeInBox(placeable, layoutDirection, boxWidth, boxHeight, childrenLayoutInfo[0])
}
}

val placeables = arrayOfNulls<Placeable>(measurables.size)
// First measure non match parent size children to get the size of the Box.
var hasMatchParentSizeChildren = false
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.fastForEachIndexed { index, measurable ->
val layoutInfo = childrenLayoutInfo[index]
if (layoutInfo.matchParentWidth || layoutInfo.matchParentHeight) {
hasMatchParentSizeChildren = true
} else {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
}
}

// Now measure match parent size children, if any.
if (hasMatchParentSizeChildren) {
measurables.fastForEachIndexed { index, measurable ->
val layoutInfo = childrenLayoutInfo[index]
if (layoutInfo.matchParentWidth || layoutInfo.matchParentHeight) {
// The infinity check is needed for default intrinsic measurements.
var childConstraints = contentConstraints
if (layoutInfo.matchParentWidth) {
childConstraints = childConstraints.copy(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
maxWidth = boxWidth,
)
}
if (layoutInfo.matchParentHeight) {
childConstraints = childConstraints.copy(
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxHeight = boxHeight,
)
}
placeables[index] = measurable.measure(childConstraints)
}
}
}

// Specify the size of the Box and position its children.
return layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeInBox(placeable!!, layoutDirection, boxWidth, boxHeight, childrenLayoutInfo[index])
}
}
}
}

private fun Placeable.PlacementScope.placeInBox(
placeable: Placeable,
layoutDirection: LayoutDirection,
boxWidth: Int,
boxHeight: Int,
layoutInfo: BoxChildLayoutInfo,
) {
val position = layoutInfo.alignment.align(
IntSize(placeable.width, placeable.height),
IntSize(boxWidth, boxHeight),
layoutDirection,
)
placeable.place(position)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package app.cash.redwood.layout.composeui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -30,29 +29,113 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.util.fastMap
import app.cash.redwood.Modifier as RedwoodModifier
import app.cash.redwood.layout.api.Constraint
import app.cash.redwood.layout.api.CrossAxisAlignment
import app.cash.redwood.layout.modifier.Height
import app.cash.redwood.layout.modifier.HorizontalAlignment
import app.cash.redwood.layout.modifier.Margin as MarginModifier
import app.cash.redwood.layout.modifier.Size
import app.cash.redwood.layout.modifier.VerticalAlignment
import app.cash.redwood.layout.modifier.Width
import app.cash.redwood.layout.widget.Box
import app.cash.redwood.ui.Density
import app.cash.redwood.ui.Margin
import app.cash.redwood.widget.compose.ComposeWidgetChildren

internal class ComposeUiBox(
private val backgroundColor: Int = 0,
) : Box<@Composable () -> Unit> {
override val children = ComposeWidgetChildren()
private var modifierTick by mutableStateOf(0)
override val children = ComposeWidgetChildren(onModifierUpdated = { modifierTick++ })

private var width by mutableStateOf(Constraint.Wrap)
private var height by mutableStateOf(Constraint.Wrap)
private var margin by mutableStateOf(Margin.Zero)
private var alignment by mutableStateOf(BiasAlignment(-1f, -1f))
private var matchParentWidth by mutableStateOf(false)
private var matchParentHeight by mutableStateOf(false)
private var density by mutableStateOf(Density(1.0))

override val value = @Composable {
Box(computeModifier(), contentAlignment = alignment) {
density = Density(LocalDensity.current.density.toDouble())

Box(
childrenLayoutInfo = computeChildrenLayoutInfo(),
modifier = computeModifier(),
) {
children.Render()
}
}

@Composable
private fun computeChildrenLayoutInfo(): BoxChildrenLayoutInfo {
// Observe the layout modifier count so we recompose if it changes.
modifierTick

return BoxChildrenLayoutInfo(
infos = children.widgets.fastMap { it.modifier.toBoxChildLayoutInfo() },
)
}

private fun RedwoodModifier.toBoxChildLayoutInfo(): BoxChildLayoutInfo {
var alignment = alignment
var matchParentWidth = matchParentWidth
var matchParentHeight = matchParentHeight

forEach { childModifier ->
when (childModifier) {
is HorizontalAlignment -> {
matchParentWidth = childModifier.alignment == CrossAxisAlignment.Stretch
alignment = BiasAlignment(
horizontalBias = alignment.horizontalBias,
verticalBias = childModifier.alignment.toBias(),
)
}

is VerticalAlignment -> {
matchParentHeight = childModifier.alignment == CrossAxisAlignment.Stretch
alignment = BiasAlignment(
horizontalBias = childModifier.alignment.toBias(),
verticalBias = alignment.verticalBias,
)
}

is Width -> {
// TODO
}

is Height -> {
// TODO
}

is Size -> {
// TODO
}

is MarginModifier -> {
// TODO
}
}
}

// TODO: Support these cases.
if (width == Constraint.Wrap && matchParentWidth) {
matchParentWidth = false
}
if (height == Constraint.Wrap && matchParentHeight) {
matchParentHeight = false
}

return BoxChildLayoutInfo(
alignment = alignment,
matchParentWidth = matchParentWidth,
matchParentHeight = matchParentHeight,
)
}

@Composable
private fun computeModifier(): Modifier {
var modifier: Modifier = Modifier
Expand Down Expand Up @@ -95,30 +178,27 @@ internal class ComposeUiBox(
}

override fun horizontalAlignment(horizontalAlignment: CrossAxisAlignment) {
matchParentWidth = horizontalAlignment == CrossAxisAlignment.Stretch
alignment = BiasAlignment(
horizontalBias = when (horizontalAlignment) {
CrossAxisAlignment.Start -> -1f
CrossAxisAlignment.Center -> 0f
CrossAxisAlignment.End -> 1f
// TODO Implement stretch with custom Layout.
CrossAxisAlignment.Stretch -> -1f
else -> throw AssertionError()
},
horizontalBias = horizontalAlignment.toBias(),
verticalBias = alignment.verticalBias,
)
}

override fun verticalAlignment(verticalAlignment: CrossAxisAlignment) {
matchParentHeight = verticalAlignment == CrossAxisAlignment.Stretch
alignment = BiasAlignment(
horizontalBias = alignment.horizontalBias,
verticalBias = when (verticalAlignment) {
CrossAxisAlignment.Start -> -1f
CrossAxisAlignment.Center -> 0f
CrossAxisAlignment.End -> 1f
// TODO Implement stretch with custom Layout.
CrossAxisAlignment.Stretch -> -1f
else -> throw AssertionError()
},
verticalBias = verticalAlignment.toBias(),
)
}

private fun CrossAxisAlignment.toBias() = when (this) {
CrossAxisAlignment.Stretch,
CrossAxisAlignment.Start,
-> -1f
CrossAxisAlignment.Center -> 0f
CrossAxisAlignment.End -> 1f
else -> throw AssertionError()
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit aa95a2e

Please sign in to comment.