Skip to content

Commit

Permalink
Add Alignment to Row, Column and Box (#233)
Browse files Browse the repository at this point in the history
* Add Alignment to Row, Column and Box

* Apply suggestions from code review

---------

Co-authored-by: Jake Wharton <github@jakewharton.com>
  • Loading branch information
EpicDima and JakeWharton authored Oct 31, 2023
1 parent 1a68525 commit f7ec16c
Show file tree
Hide file tree
Showing 12 changed files with 603 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jakewharton.mosaic.layout

/**
* A part of the composition that can be measured. This represents a layout.
* The instance should never be stored.
*/
public interface IntrinsicMeasurable {
/**
* Data provided by the `ParentData`
*/
public val parentData: Any?
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.jakewharton.mosaic.layout

public interface Measurable {
public interface Measurable : IntrinsicMeasurable {
public fun measure(): Placeable
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ internal abstract class AbstractMosaicNodeLayer(
) : MosaicNodeLayer() {
private var measureResult: MeasureResult = NotMeasured

final override var parentData: Any? = null

final override val width get() = measureResult.width
final override val height get() = measureResult.height

Expand Down Expand Up @@ -77,12 +79,19 @@ internal class MosaicNode(
var topLayer: MosaicNodeLayer = bottomLayer
private set

override var parentData: Any? = null
private set

var modifiers: Modifier = Modifier
set(value) {
topLayer = value.foldOut(bottomLayer) { element, lowerLayer ->
when (element) {
is LayoutModifier -> LayoutLayer(element, lowerLayer)
is DrawModifier -> DrawLayer(element, lowerLayer)
is ParentDataModifier -> {
parentData = element.modifyParentData(parentData)
lowerLayer
}
else -> lowerLayer
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.jakewharton.mosaic.layout

import com.jakewharton.mosaic.modifier.Modifier

/**
* A [Modifier] that provides data to the parent [Layout]. This can be read from within the
* the [Layout] during measurement and positioning, via [IntrinsicMeasurable.parentData].
* The parent data is commonly used to inform the parent how the child [Layout] should be measured
* and positioned.
*/
public interface ParentDataModifier : Modifier.Element {
/**
* Provides a parentData, given the [parentData] already provided through the modifier's chain.
*/
public fun modifyParentData(parentData: Any?): Any?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.jakewharton.mosaic.ui

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.jakewharton.mosaic.ui.unit.IntOffset
import com.jakewharton.mosaic.ui.unit.IntSize
import kotlin.math.roundToInt

/**
* An interface to calculate the position of a sized box inside an available space. [Alignment] is
* often used to define the alignment of a layout inside a parent layout.
*
* @see BiasAlignment
*/
@Stable
public fun interface Alignment {
/**
* Calculates the position of a box of size [size] relative to the top left corner of an area
* of size [space]. The returned offset can be negative or larger than `space - size`,
* meaning that the box will be positioned partially or completely outside the area.
*/
public fun align(size: IntSize, space: IntSize): IntOffset

/**
* An interface to calculate the position of box of a certain width inside an available width.
* [Alignment.Horizontal] is often used to define the horizontal alignment of a layout inside a
* parent layout.
*/
@Stable
public fun interface Horizontal {
/**
* Calculates the horizontal position of a box of width [size] relative to the left
* side of an area of width [space]. The returned offset can be negative or larger than
* `space - size` meaning that the box will be positioned partially or completely outside
* the area.
*/
public fun align(size: Int, space: Int): Int
}

/**
* An interface to calculate the position of a box of a certain height inside an available
* height. [Alignment.Vertical] is often used to define the vertical alignment of a
* layout inside a parent layout.
*/
@Stable
public fun interface Vertical {
/**
* Calculates the vertical position of a box of height [size] relative to the top edge of
* an area of height [space]. The returned offset can be negative or larger than
* `space - size` meaning that the box will be positioned partially or completely outside
* the area.
*/
public fun align(size: Int, space: Int): Int
}

/**
* A collection of common [Alignment]s aware of layout direction.
*/
public companion object {
// 2D Alignments.
@Stable
public val TopStart: Alignment = BiasAlignment(-1f, -1f)

@Stable
public val TopCenter: Alignment = BiasAlignment(0f, -1f)

@Stable
public val TopEnd: Alignment = BiasAlignment(1f, -1f)

@Stable
public val CenterStart: Alignment = BiasAlignment(-1f, 0f)

@Stable
public val Center: Alignment = BiasAlignment(0f, 0f)

@Stable
public val CenterEnd: Alignment = BiasAlignment(1f, 0f)

@Stable
public val BottomStart: Alignment = BiasAlignment(-1f, 1f)

@Stable
public val BottomCenter: Alignment = BiasAlignment(0f, 1f)

@Stable
public val BottomEnd: Alignment = BiasAlignment(1f, 1f)

// 1D Alignment.Verticals.
@Stable
public val Top: Vertical = BiasAlignment.Vertical(-1f)

@Stable
public val CenterVertically: Vertical = BiasAlignment.Vertical(0f)

@Stable
public val Bottom: Vertical = BiasAlignment.Vertical(1f)

// 1D Alignment.Horizontals.
@Stable
public val Start: Horizontal = BiasAlignment.Horizontal(-1f)

@Stable
public val CenterHorizontally: Horizontal = BiasAlignment.Horizontal(0f)

@Stable
public val End: Horizontal = BiasAlignment.Horizontal(1f)
}
}

/**
* An [Alignment] specified by bias: for example, a bias of -1 represents alignment to the
* start/top, a bias of 0 will represent centering, and a bias of 1 will represent end/bottom.
* Any value can be specified to obtain an alignment. Inside the [-1, 1] range, the obtained
* alignment will position the aligned size fully inside the available space, while outside the
* range it will the aligned size will be positioned partially or completely outside.
*
* @see Alignment
*/
@Immutable
public data class BiasAlignment(
val horizontalBias: Float,
val verticalBias: Float
) : Alignment {

override fun align(size: IntSize, space: IntSize): IntOffset {
// Convert to cells first and only round at the end, to avoid rounding twice while calculating
// the new positions
val centerX = (space.width - size.width).toFloat() / 2f
val centerY = (space.height - size.height).toFloat() / 2f

val x = centerX * (1 + horizontalBias)
val y = centerY * (1 + verticalBias)
return IntOffset(x.roundToInt(), y.roundToInt())
}

/**
* An [Alignment.Horizontal] specified by bias: for example, a bias of -1 represents alignment
* to the start, a bias of 0 will represent centering, and a bias of 1 will represent end.
* Any value can be specified to obtain an alignment. Inside the [-1, 1] range, the obtained
* alignment will position the aligned size fully inside the available space, while outside the
* range it will the aligned size will be positioned partially or completely outside.
*
* @see Vertical
*/
@Immutable
public data class Horizontal(private val bias: Float) : Alignment.Horizontal {
override fun align(size: Int, space: Int): Int {
// Convert to cells first and only round at the end, to avoid rounding twice while
// calculating the new positions.
val center = (space - size).toFloat() / 2f
return (center * (1 + bias)).roundToInt()
}
}

/**
* An [Alignment.Vertical] specified by bias: for example, a bias of -1 represents alignment
* to the top, a bias of 0 will represent centering, and a bias of 1 will represent bottom.
* Any value can be specified to obtain an alignment. Inside the [-1, 1] range, the obtained
* alignment will position the aligned size fully inside the available space, while outside the
* range it will the aligned size will be positioned partially or completely outside.
*
* @see Horizontal
*/
@Immutable
public data class Vertical(private val bias: Float) : Alignment.Vertical {
override fun align(size: Int, space: Int): Int {
// Convert to cells first and only round at the end, to avoid rounding twice while
// calculating the new positions.
val center = (space - size).toFloat() / 2f
return (center * (1 + bias)).roundToInt()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,35 @@
package com.jakewharton.mosaic.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.jakewharton.mosaic.layout.Measurable
import com.jakewharton.mosaic.layout.MeasurePolicy
import com.jakewharton.mosaic.layout.MeasureResult
import com.jakewharton.mosaic.layout.MeasureScope
import com.jakewharton.mosaic.layout.ParentDataModifier
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.unit.IntSize
import kotlin.jvm.JvmName

@Composable
public fun Box(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit,
) {
Layout(content, modifier, { "Box()" }, BoxMeasurePolicy())
Layout(
content = { BoxScopeInstance.content() },
modifiers = modifier,
debugInfo = { "Box()" },
measurePolicy = BoxMeasurePolicy(contentAlignment),
)
}

internal class BoxMeasurePolicy : MeasurePolicy {
internal class BoxMeasurePolicy(
private val contentAlignment: Alignment = Alignment.TopStart,
) : MeasurePolicy {

override fun MeasureScope.measure(measurables: List<Measurable>): MeasureResult {
var width = 0
var height = 0
Expand All @@ -29,9 +42,54 @@ internal class BoxMeasurePolicy : MeasurePolicy {
}
}
return layout(width, height) {
for (placeable in placeables) {
placeable.place(0, 0)
placeables.forEachIndexed { index, placeable ->
val alignment = measurables[index].boxParentData?.alignment ?: contentAlignment
val offset =
alignment.align(IntSize(placeable.width, placeable.height), IntSize(width, height))
placeable.place(offset.x, offset.y)
}
}
}
}

/**
* A BoxScope provides a scope for the children of [Box].
*/
@LayoutScopeMarker
@Immutable
public interface BoxScope {
/**
* Pull the content element to a specific [Alignment] within the [Box]. This alignment will
* have priority over the [Box]'s `alignment` parameter.
*/
@Stable
public fun Modifier.align(alignment: Alignment): Modifier
}

private object BoxScopeInstance : BoxScope {

@Stable
override fun Modifier.align(alignment: Alignment) = this.then(
AlignModifier(alignment = alignment)
)
}

private class AlignModifier(
private val alignment: Alignment,
) : ParentDataModifier {

override fun modifyParentData(parentData: Any?): Any {
return ((parentData as? BoxParentData) ?: BoxParentData()).also {
it.alignment = alignment
}
}

override fun toString(): String = "Align($alignment)"
}

private data class BoxParentData(
var alignment: Alignment = Alignment.TopStart,
)

private val Measurable.boxParentData: BoxParentData?
get() = this.parentData as? BoxParentData
Loading

0 comments on commit f7ec16c

Please sign in to comment.