티스토리 뷰
Compose UI의 측정은 LayoutNode 트리를 화면에 렌더링하기 위해 각 UI 요소의 크기와 위치를 계산하는 과정입니다. 이 측정은 Modifier, LayoutNodeWrapper, MeasurePolicy, Constraints, Intrinsic Measurements 등 다양한 컴포넌트의 상호작용을 통해 수행됩니다.
Owner를 통한 재측정 요청
LayoutNode의 구조적 변화(자식 추가, 제거, 이동 등)에 따라 크기나 위치 계산이 다시 필요할 때 Owner를 통해 재측정을 요청합니다.
- 재측정 트리거: LayoutNode는 자식의 추가(attach), 제거(detach), 이동 등 구조 변경 시 requestRemeasure()를 통해 자신과 부모에 재측정을 요청합니다.
- 요청 경로: 재측정 요청은 Owner에게 전달되며, Android에서는 AndroidComposeView가 이를 구현합니다.
- 무효화 처리: 요청된 노드는 dirty 상태로 표시되고, 이후 재측정/재배치 대상으로 스케줄됩니다.
- 실제 적용 시점: 다음 프레임의 dispatchDraw() 호출 시, 예약된 노드를 순회하며 재측정, 재배치, 연기된 요청 처리 순으로 실행됩니다.
- 측정 위임 구조: 측정은 LayoutNodeWrapper 체인을 따라 진행되며, Modifier들이 순서대로 적용됩니다.
- 측정 결과 전파: 측정 결과 크기가 변경되면 부모 노드 또는 루트 노드의 requestLayout()을 통해 Android View 시스템까지 반영됩니다.
/**
* 이 LayoutNode의 [Owner]를 설정합니다.
* 이 LayoutNode는 이미 attach(연결)되어 있어서는 안 됩니다.
* 전달된 [owner]는 이 노드의 [parent]의 [owner]와 동일해야 합니다.
*/
internal fun attach(owner: Owner) {
checkPrecondition(this.owner == null) {
"Cannot attach $this as it already is attached. Tree: " + debugTreeToString()
}
checkPrecondition(_foldedParent == null || _foldedParent?.owner == owner) {
"Attaching to a different owner($owner) than the parent's owner(${parent?.owner})." +
" This tree: " + debugTreeToString() +
" Parent tree: " + _foldedParent?.debugTreeToString()
}
val parent = this.parent
if (parent == null) {
// 루트 노드인 경우, 부모가 없으므로 항상 배치된 상태로 간주됩니다.
// (루트 노드는 부모에 의해 명시적으로 배치되지 않기 때문)
measurePassDelegate.isPlaced = true
lookaheadPassDelegate?.let { it.isPlaced = true }
}
// 첫 번째 비가상(virtual이 아닌) 부모의 내부 코디네이터(innerCoordinator)를 사용합니다.
outerCoordinator.wrappedBy = parent?.innerCoordinator
this.owner = owner
this.depth = (parent?.depth ?: -1) + 1
pendingModifier?.let { applyModifier(it) }
pendingModifier = null
if (nodes.has(Nodes.Semantics)) {
invalidateSemantics()
}
// Owner에 이 LayoutNode가 연결되었음을 알립니다.
owner.onAttach(this)
// attach 시 Lookahead root 갱신
// 중첩된 경우 항상 가장 가까운 Lookahead root를 사용합니다.
if (isVirtualLookaheadRoot) {
lookaheadRoot = this
} else {
// 현재 노드가 virtual lookahead root가 아닌 한,
// 부모의 lookahead root를 우선적으로 사용합니다.
lookaheadRoot = _foldedParent?.lookaheadRoot ?: lookaheadRoot
if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) {
// MovableContent 내에 intermediateLayout이 이동된 경우 발생할 수 있음
lookaheadRoot = this
}
}
if (!isDeactivated) {
nodes.markAsAttached()
}
// 모든 자식 노드에 대해 attach 수행
_foldedChildren.forEach { child ->
child.attach(owner)
}
if (!isDeactivated) {
nodes.runAttachLifecycle()
}
// 측정 무효화 (재측정이 필요함을 표시)
invalidateMeasurements()
parent?.invalidateMeasurements()
// 내부 및 외부 코디네이터에 attach 이벤트 전달
forEachCoordinatorIncludingInner { it.onLayoutNodeAttach() }
// attach 시 등록된 콜백 호출
onAttach?.invoke(owner)
// 부모 데이터 갱신 (modifier chain의 parent 관련 데이터 업데이트)
layoutDelegate.updateParentData()
if (!isDeactivated) {
invalidateFocusOnAttach()
}
}
측정 정책(MeasurePolicy)
- MeasurePolicy는 LayoutNode의 자식들을 측정하고 배치하는 로직을 정의
- 최소/최대 너비와 높이로 구성된 Constraints를 기반으로 노드의 크기를 결정
- LayoutNode에 할당된 측정 정책에서 읽은 상태 값이 변경되면 자동으로 재측정이 발생해 UI가 반응적으로 갱신
- 측정은 Modifier 래퍼 체인을 따라 위임되며, 마지막에 측정 정책이 자식을 측정
// [MeasurePolicy]는 [Layout]의 측정 및 배치 동작을 정의합니다.
fun interface MeasurePolicy {
// 실제 측정 및 배치를 정의하는 함수입니다.
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
//...
}
interface LayoutInfo {
fun getModifierInfo(): List<ModifierInfo>
val width: Int
val height: Int
val coordinates: LayoutCoordinates
val isPlaced: Boolean
val parentInfo: LayoutInfo?
val density: Density
val layoutDirection: LayoutDirection
val viewConfiguration: ViewConfiguration
val isAttached: Boolean
val semanticsId: Int
val isDeactivated: Boolean get() = false
}
class ModifierInfo(
val modifier: Modifier,
val coordinates: LayoutCoordinates,
val extra: Any? = null
) {
override fun toString(): String {
return "ModifierInfo($modifier, $coordinates, $extra)"
}
}
예시로 Box를 살펴보면 다음과 같습니다.
// 1
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
val measurePolicy = maybeCachedBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { BoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
// 2
internal fun maybeCachedBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean
): MeasurePolicy {
val cache = if (propagateMinConstraints) cache1 else cache2
return cache[alignment] ?: BoxMeasurePolicy(alignment, propagateMinConstraints)
}
// 3
private data class BoxMeasurePolicy(
private val alignment: Alignment,
private val propagateMinConstraints: Boolean
) : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
// 빈 Box인 경우
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 boxWidth: Int
val boxHeight: Int
val placeable: Placeable
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
} else {
boxWidth = constraints.minWidth
boxHeight = constraints.minHeight
placeable = measurable.measure(
Constraints.fixed(constraints.minWidth, constraints.minHeight)
)
}
// 리턴해버림
return layout(boxWidth, boxHeight) {
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
// 다중 자식인 경우
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
// 먼저 matchParent가 아닌 자식들부터 측정
measurables.fastForEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
} else {
hasMatchParentSizeChildren = true
}
}
// 그 다음 matchParent인 자식들 측정
if (hasMatchParentSizeChildren) {
val matchParentSizeConstraints = Constraints(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxWidth = boxWidth,
maxHeight = boxHeight
)
measurables.fastForEachIndexed { index, measurable ->
if (measurable.matchesParentSize) {
placeables[index] = measurable.measure(matchParentSizeConstraints)
}
}
}
return layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val measurable = measurables[index]
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
}
}
제약 조건(Constraints)
측정 정책은 노드와 자식 노드의 크기를 결정하기 위해 제약 조건을 필수적으로 사용합니다.
- 제약 조건은 너비와 높이에 대한 최소값(min)과 최대값(max)으로 구성된 픽셀 단위의 범위이며, 측정된 레이아웃(자식)은 이 범위 내에 맞아야 합니다.
- 측정 정책은 이 제약 조건을 사용하여 레이아웃의 너비와 높이를 결정합니다.
@Immutable
@JvmInline
value class Constraints(val value: Long) {
val minWidth: Int get() = ...
val maxWidth: Int get() = ...
val minHeight: Int get() = ...
val maxHeight: Int get() = ...
val hasBoundedWidth: Boolean get() = ...
val hasBoundedHeight: Boolean get() = ...
val hasFixedWidth: Boolean get() = ...
val hasFixedHeight: Boolean get() = ...
val isZero: Boolean get() = ...
fun copy(
minWidth: Int = this.minWidth,
maxWidth: Int = this.maxWidth,
minHeight: Int = this.minHeight,
maxHeight: Int = this.maxHeight
): Constraints = createConstraints(minWidth, maxWidth, minHeight, maxHeight)
companion object {
const val Infinity = Int.MAX_VALUE
fun fixed(width: Int, height: Int): Constraints =
createConstraints(width, width, height, height)
fun fixedWidth(width: Int): Constraints =
createConstraints(width, width, 0, Infinity)
fun fixedHeight(height: Int): Constraints =
createConstraints(0, Infinity, height, height)
fun fitPrioritizingWidth(minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int): Constraints = ...
fun fitPrioritizingHeight(minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int): Constraints = ...
}
}
internal fun createConstraints(
minWidth: Int,
maxWidth: Int,
minHeight: Int,
maxHeight: Int
): Constraints {
val heightVal = if (maxHeight == Infinity) minHeight else maxHeight
val heightBits = bitsNeedForSizeUnchecked(heightVal)
val widthVal = if (maxWidth == Infinity) minWidth else maxWidth
val widthBits = bitsNeedForSizeUnchecked(widthVal)
if (widthBits + heightBits > 31) {
invalidConstraint(widthVal, heightVal)
}
// Same as if (maxWidth == Infinity) 0 else maxWidth + 1 but branchless
// in DEX and saves 2 instructions on aarch64. Relies on integer overflow
// since Infinity == Int.MAX_VALUE
var maxWidthValue = maxWidth + 1
maxWidthValue = maxWidthValue and (maxWidthValue shr 31).inv()
var maxHeightValue = maxHeight + 1
maxHeightValue = maxHeightValue and (maxHeightValue shr 31).inv()
val focus = when (widthBits) {
MinNonFocusBits -> MinFocusHeight
MinFocusBits -> MinFocusWidth
MaxNonFocusBits -> MaxFocusHeight
MaxFocusBits -> MaxFocusWidth
else -> 0x00 // can't happen, widthBits is computed from bitsNeedForSizeUnchecked()
}
val minHeightOffset = minHeightOffsets(indexToBitOffset(focus))
val maxHeightOffset = minHeightOffset + 31
val value = focus.toLong() or
(minWidth.toLong() shl 2) or
(maxWidthValue.toLong() shl 33) or
(minHeight.toLong() shl minHeightOffset) or
(maxHeightValue.toLong() shl maxHeightOffset)
return Constraints(value)
}
fun Spacer(modifier: Modifier) {
Layout(measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}
private object SpacerMeasurePolicy : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
return with(constraints) {
val width = if (hasFixedWidth) maxWidth else 0
val height = if (hasFixedHeight) maxHeight else 0
layout(width, height) {}
}
}
}
@Composable
fun Box(modifier: Modifier) {
Layout(measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}
internal val EmptyBoxMeasurePolicy = MeasurePolicy { _, constraints ->
layout(constraints.minWidth, constraints.minHeight) {}
}
LayoutNodeWrapper 체인
(현재는 NodeCoordinator로 이름이 변경됨)
LayoutNodeWrapper 체인은 Compose UI에서 측정(measuring)과 그리기(drawing) 로직을 순차적으로 적용하고 관리하는 핵심 구조입니다. 이는 특히 Modifier가 UI 요소의 크기나 레이아웃에 영향을 미칠 때 중요한 역할을 합니다.
LayoutNodeWrapper 체인의 구조 및 구성 요소
- LayoutNode는 여러 개의 LayoutNodeWrapper로 이루어진 체인을 갖고 있으며, 이 체인은 측정과 그리기 로직을 순차적으로 적용하는 데 사용됩니다. 이 체인은 세 가지 종류의 래퍼로 구성되어 있으며, Modifier들이 정의된 순서대로 정확하게 측정과 그리기가 이뤄지도록 보장합니다.
- 외부 래퍼(Outer): 현재 노드의 측정과 그리기를 담당
- Modifier 래퍼: LayoutNode에 적용된 Modifier 하나하나를 감싸며 상태를 유지하고 관련 기능(draw, hit test 등)을 담당
- 내부 래퍼(Inner): 현재 노드의 자식 노드들을 측정하고 그리는 데 사용
측정 프로세스와 체인 내 역할 분담
- 측정은 LayoutNode에 재측정 요청이 들어오면서 시작되며, 외부 래퍼에서 최초로 측정이 호출됩니다.
- 부모 LayoutNode는 자식의 외부 래퍼를 측정할 때 자신의 MeasurePolicy를 사용하여 제약조건(Constraints)을 전달합니다.
- 이 과정에서 Modifier 체인을 따라 외부에서 내부로 측정 호출이 순차적으로 위임되며, 각 Modifier 래퍼는 자식에게 전달할 제약조건을 수정하거나 레이아웃 동작을 추가할 수 있습니다.
- 체인의 마지막에 도달하면 내부 래퍼가 자식 노드들의 외부 래퍼를 재측정하여 자식들의 레이아웃을 완성합니다.
- 측정 결과에 따라 현재 노드의 크기가 바뀌면, 부모 노드에게 재측정 또는 재배치를 요청하게 됩니다.
체인의 연결 및 LayoutNode 삽입
- 새로운 LayoutNode가 Compose 트리에 삽입될 때, UiApplier는 LayoutNode.insertAt()을 호출하여 해당 노드를 부모와 연결합니다.
- 이 과정에서 새 노드의 외부 래퍼는 부모 노드의 내부 래퍼에 의해 감싸지도록 설정되며, 이를 통해 체인이 자연스럽게 연결됩니다.
- 이 구조는 부모에서 자식으로 연결된 Modifier 및 측정 체인을 형성하며, 측정과 그리기 로직이 계층적으로 일관되게 흐를 수 있도록 합니다.
// LayoutNode.kt
internal fun insertAt(index: Int, instance: LayoutNode) {
instance._foldedParent = this
_foldedChildren.add(index, instance)
onZSortedChildrenInvalidated()
if (instance.isVirtual) {
virtualChildrenCount++
}
invalidateUnfoldedVirtualChildren()
val owner = this.owner
if (owner != null) {
instance.attach(owner)
}
if (instance.layoutDelegate.childrenAccessingCoordinatesDuringPlacement > 0) {
layoutDelegate.childrenAccessingCoordinatesDuringPlacement++
}
}
internal abstract class NodeCoordinator(
override val layoutNode: LayoutNode
) : LookaheadCapablePlaceable(), Measurable, LayoutCoordinates, OwnerScope {
internal var wrapped: NodeCoordinator? = null
internal var wrappedBy: NodeCoordinator? = null
abstract val tail: Modifier.Node
override val layoutDirection get() = layoutNode.layoutDirection
override val density get() = layoutNode.density.density
override val fontScale get() = layoutNode.density.fontScale
override val coordinates get() = this
override val parent get() = wrappedBy
override val alignmentLinesOwner get() = layoutNode.layoutDelegate.alignmentLinesOwner
override val child get() = wrapped
override val isAttached get() = tail.isAttached
override val hasMeasureResult get() = _measureResult != null
private var released = false
private var _measureResult: MeasureResult? = null
override var measureResult: MeasureResult
get() = _measureResult ?: error("Unmeasured")
internal set(value) {
_measureResult = value
measuredSize = IntSize(value.width, value.height)
}
final override val size: IntSize get() = measuredSize
override var position: IntOffset = IntOffset.Zero
protected set
var zIndex: Float = 0f
protected set
var layer: OwnedLayer? = null
private set
private var explicitLayer: GraphicsLayer? = null
protected var layerBlock: (GraphicsLayerScope.() -> Unit)? = null
open fun placeAt(position: IntOffset, zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)?) {
placeSelf(position, zIndex, layerBlock)
}
private fun placeSelf(position: IntOffset, zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)?) {
if (this.position != position) {
this.position = position
layoutNode.owner?.onLayoutChange(layoutNode)
}
this.zIndex = zIndex
this.layerBlock = layerBlock
}
fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
val x = position.x.toFloat()
val y = position.y.toFloat()
canvas.translate(x, y)
wrapped?.draw(canvas, graphicsLayer)
canvas.translate(-x, -y)
}
open fun onPlaced() {
layoutNode.owner?.onLayoutChange(layoutNode)
}
open fun invalidateLayer() {
layer?.invalidate() ?: wrappedBy?.invalidateLayer()
}
fun onRelease() {
released = true
invalidateLayer()
layoutNode.requestRelayout()
}
}
Modifier 체인 모델링
Modifier 체인 모델링은 Compose UI에서 Composable에 기능(동작, 스타일 등)을 추가하는 불변 객체들의 연결 리스트 구조이며, 측정과 그리기 과정에 직접적으로 관여하는 구성 요소입니다.
- Modifier는 자신을 감싸는 방식으로 연결되는 연결 리스트(linked list) 형태로 구성되며, 각 Modifier는 다음 Modifier를 감싼 구조를 형성합니다.
- then() 함수로 두 Modifier가 결합되면 CombinedModifier 객체가 생성되며, 이는 outer(현재 Modifier)와 inner(다음 Modifier)를 참조하여 전체 체인을 구성합니다.
// Modifier.kt
infix fun then(other: Modifier): Modifier =
if (other === Modifier) this else CombinedModifier(this, other)
- 여러 Modifier가 연결될 경우, 중첩 구조로 표현됩니다.
// Modifier.kt
class CombinedModifier(
private val outer: Modifier,
private val inner: Modifier
) : Modifier
- Modifier는 명시적으로 then()을 사용하거나, Modifier.padding().background()와 같은 방식으로 암묵적으로 연결됩니다. 내부적으로는 모두 then()을 호출하여 결합합니다.
- Modifier는 불변(immutable) 객체이기 때문에 체인이 결합될 때마다 새로운 CombinedModifier 인스턴스가 생성됩니다.
- Modifier 체인은 LayoutNode의 측정(measuring)과 그리기(drawing) 과정에서 중요한 역할을 하며, Modifier에 해당하는 래퍼들이 순서대로 적용됩니다.
- 측정 과정은 LayoutNode의 외부 래퍼에서 LayoutNode.measure()가 호출되면서 시작되어 Modifier 체인을 따라 각 Modifier가 순서대로 개입하며 진행됩니다. 각 Modifier는 자신만의 측정 로직을 통해 자식에게 전달할 Constraints를 조정하거나 새로운 레이아웃 동작을 추가할 수 있습니다.
- 그리기 단계에서는 LayoutNode.draw()에서 시작되며, outerCoordinator.draw(canvas) 호출이 Modifier 체인을 따라 전달되며, 각 Modifier는 순서대로 시각적 효과를 적용합니다. DrawModifier들은 배경, 테두리, 그림자 등 다양한 그래픽 효과를 그리기 순서에 따라 렌더링하며, 각 Modifier는 drawContent()를 통해 자식 콘텐츠를 이어서 그릴 수 있습니다.
- Modifier는 foldIn (head→tail), foldOut (tail→head) 메서드를 통해 Modifier 체인 내의 Modifier들을 순차적으로 순회하며 값을 누적하거나 처리할 수 있는 기능을 제공합니다.
// 이랬던 애가
Modifier
.padding(8.dp)
.background(Color.Red)
.border(2.dp, Color.Black)
-------------------------------------------------------------
@Stable
fun Modifier.padding(all: Dp) = this then PaddingElement(
start = all,
top = all,
end = all,
bottom = all,
rtlAware = true,
inspectorInfo = {
name = "padding"
value = all
}
)
@Stable
fun Modifier.background(
brush: Brush,
shape: Shape = RectangleShape,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1.0f
) = this.then(
BackgroundElement(
brush = brush,
alpha = alpha,
shape = shape,
inspectorInfo = debugInspectorInfo {
name = "background"
properties["alpha"] = alpha
properties["brush"] = brush
properties["shape"] = shape
}
)
)
@Stable
fun Modifier.border(width: Dp, brush: Brush, shape: Shape) =
this then BorderModifierNodeElement(width, brush, shape)
-------------------------------------------------------------
// 이렇게 된다!
Modifier
.then(PaddingModifier)
.then(BackgroundModifier)
.then(BorderModifier)
interface Modifier {
fun <R> foldIn(initial: R, operation: (R, Element) -> R): R
fun <R> foldOut(initial: R, operation: (Element, R) -> R): R
fun any(predicate: (Element) -> Boolean): Boolean
fun all(predicate: (Element) -> Boolean): Boolean
infix fun then(other: Modifier): Modifier =
if (other === Modifier) this else CombinedModifier(this, other)
interface Element : Modifier {
override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R =
operation(this, initial)
override fun any(predicate: (Element) -> Boolean): Boolean = predicate(this)
override fun all(predicate: (Element) -> Boolean): Boolean = predicate(this)
}
companion object : Modifier {
override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R = initial
override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R = initial
override fun any(predicate: (Element) -> Boolean): Boolean = false
override fun all(predicate: (Element) -> Boolean): Boolean = true
override infix fun then(other: Modifier): Modifier = other
override fun toString() = "Modifier"
}
}
class CombinedModifier(
internal val outer: Modifier,
internal val inner: Modifier
) : Modifier {
override fun <R> foldIn(initial: R, operation: (R, Modifier.Element) -> R): R =
inner.foldIn(outer.foldIn(initial, operation), operation)
override fun <R> foldOut(initial: R, operation: (Modifier.Element, R) -> R): R =
outer.foldOut(inner.foldOut(initial, operation), operation)
override fun any(predicate: (Modifier.Element) -> Boolean): Boolean =
outer.any(predicate) || inner.any(predicate)
override fun all(predicate: (Modifier.Element) -> Boolean): Boolean =
outer.all(predicate) && inner.all(predicate)
override fun equals(other: Any?): Boolean =
other is CombinedModifier && outer == other.outer && inner == other.inner
override fun hashCode(): Int = outer.hashCode() + 31 * inner.hashCode()
override fun toString(): String = "[" + foldIn("") { acc, element ->
if (acc.isEmpty()) element.toString() else "$acc, $element"
} + "]"
}
노드 트리 그리기(Drawing the node tree)
그리기 레이어(RenderNodeLayer 및 ViewLayer)는 Compose UI가 LayoutNode 트리를 안드로이드 캔버스에 효율적으로 렌더링하고 하드웨어 가속을 활용할 수 있도록 지원합니다.
- 그리기 레이어는 LayoutNode의 렌더링 명령을 플랫폼에 전달하는 핵심 수단이며, 그리기 시작은 루트 LayoutNode의 외부 래퍼에서 이루어짐
- AndroidComposeView는 dispatchDraw 이전에 모든 LayoutNode의 그리기 레이어를 무효화하여 다시 그려야 함을 표시
- LayoutNodeWrapper 체인을 따라 그리기 레이어가 있으면 해당 레이어에 그리기를 지시하며, Modifier 및 자식 노드도 순차적으로 그려짐
- RenderNodeLayer: 기본 그리기 레이어로 RenderNode를 기반으로 하드웨어 가속을 적극 활용하며, 효율적인 재사용이 가능
- ViewLayer: RenderNode 사용이 불가능할 때 사용하는 대체 방식으로 ViewGroup.drawChild()를 통해 그리기를 처리
- 두 레이어 타입 모두 직접 또는 간접적으로 RenderNode에 의존하여 하드웨어 가속을 지원
- 어떤 레이어를 사용할지는 Owner가 결정하며, AndroidComposeView는 지원 시 RenderNodeLayer를 우선 사용
- 루트 LayoutNode의 그리기 레이어는 Compose 상태를 관찰하며, 변경 시 자동으로 무효화되어 다시 그려짐
internal class RenderNodeLayer(
val ownerView: AndroidComposeView,
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit,
) : OwnedLayer, GraphicLayerInfo {
private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
private var isDirty = false
private var transformOrigin: TransformOrigin = TransformOrigin.Center
private val renderNode = RenderNodeApi29(ownerView).apply {
setHasOverlappingRendering(true)
clipToBounds = false
}
private val matrixCache = LayerMatrixCache { rn, matrix -> rn.getMatrix(matrix) }
private val canvasHolder = CanvasHolder()
override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
renderNode.scaleX = scope.scaleX
renderNode.scaleY = scope.scaleY
renderNode.alpha = scope.alpha
renderNode.translationX = scope.translationX
renderNode.translationY = scope.translationY
renderNode.rotationZ = scope.rotationZ
renderNode.elevation = scope.shadowElevation
renderNode.clipToBounds = scope.clip
renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
matrixCache.invalidate()
triggerRepaint()
}
override fun resize(size: IntSize) {
renderNode.setPosition(
renderNode.left,
renderNode.top,
renderNode.left + size.width,
renderNode.top + size.height
)
renderNode.pivotX = transformOrigin.pivotFractionX * size.width
renderNode.pivotY = transformOrigin.pivotFractionY * size.height
invalidate()
}
override fun move(position: IntOffset) {
renderNode.offsetLeftAndRight(position.x - renderNode.left)
renderNode.offsetTopAndBottom(position.y - renderNode.top)
triggerRepaint()
matrixCache.invalidate()
}
override fun invalidate() {
if (!isDirty) {
ownerView.invalidate()
isDirty = true
}
}
private fun triggerRepaint() {
ownerView.invalidate()
}
override fun drawLayer(canvas: Canvas, parentLayer: GraphicsLayer?) {
updateDisplayList()
renderNode.drawInto(canvas.nativeCanvas)
}
override fun updateDisplayList() {
drawBlock?.let { renderNode.record(canvasHolder, null) { it(it, null) } }
isDirty = false
}
override fun destroy() {
drawBlock = null
invalidateParentLayer = null
isDirty = false
}
override fun reuseLayer(
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit
) {
matrixCache.reset()
isDirty = false
this.drawBlock = drawBlock
this.invalidateParentLayer = invalidateParentLayer
}
override fun transform(matrix: Matrix) {
matrix.timesAssign(matrixCache.calculateMatrix(renderNode))
}
override fun inverseTransform(matrix: Matrix) {
matrixCache.calculateInverseMatrix(renderNode)?.let {
matrix.timesAssign(it)
}
}
}
Jetpack Compose에서의 Semantics (의미론)
Semantics 트리는 Compose UI를 접근성 서비스나 테스팅 도구가 이해할 수 있도록 설명하는 별도의 트리 구조입니다.
- UI 트리와 별개로 존재하는 의미론적 트리로, 노드는 label, description, 상태 정보 등 메타데이터를 가집니다.
- 접근성 및 UI 테스트에 필수 요소입니다.
- Owner (AndroidComposeView)가 Semantics 트리와 Android 접근성 시스템을 연결합니다.
- setContent 시 기본 Semantics Modifier가 적용되어 트리 루트가 구성됩니다.
- Semantics가 바뀌면 Owner가 접근성 이벤트를 트리거합니다.
- 트리 구조나 속성이 바뀌면 시스템에 접근성 이벤트로 알림을 보냅니다.
- Android SDK의 AccessibilityDelegateCompat를 통해 전달됩니다.
- Modifier.semantics { } 로 의미를 지정할 수 있습니다.
- 내부적으로 고유 ID를 기억하고, composed Modifier로 구성됩니다.
- Compose는 병합된(merged) Semantics 트리와 병합되지 않은(unmerged) 트리를 유지합니다.
@Composable
fun Icon(
painter: Painter,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) {
val colorFilter =
remember(tint) { if (tint == Color.Unspecified) null else ColorFilter.tint(tint) }
// Compose Semantics
val semantics =
if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
Box(
modifier
.toolingGraphicsLayer()
.defaultSizeFor(painter)
.paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit)
.then(semantics)
)
}
/**
* 사용자 인터페이스 요소의 유형입니다. 접근성 서비스는 이를 사용하여 요소를 설명하거나
* 사용자 정의를 수행할 수 있습니다. 대부분의 역할은 이 요소의 semantics 속성을 통해
* 자동으로 해결할 수 있습니다. 그러나 미묘한 차이가 있는 일부 요소는 정확한 역할 지정이 필요합니다.
* 정확한 역할이 나열되지 않은 경우, [SemanticsPropertyReceiver.role]은 설정하지 않아야 하며,
* 프레임워크가 자동으로 역할을 결정합니다.
*/
@Immutable
@JvmInline
value class Role private constructor(@Suppress("unused") private val value: Int) {
companion object {
// 아래 7가지만 지원하는 듯 하다.
val Button = Role(0)
val Checkbox = Role(1)
val Switch = Role(2)
val RadioButton = Role(3)
val Tab = Role(4)
val Image = Role(5)
val DropdownList = Role(6)
}
override fun toString() = when (this) {
Button -> "Button"
Checkbox -> "Checkbox"
Switch -> "Switch"
RadioButton -> "RadioButton"
Tab -> "Tab"
Image -> "Image"
DropdownList -> "DropdownList"
else -> "Unknown"
}
}'Android > Compose' 카테고리의 다른 글
| 이펙트 및 이펙트 핸들러 (0) | 2025.11.16 |
|---|---|
| Compose UI(1) - LayoutNode, Materialization (1) | 2025.04.13 |
| Compose Runtime(2) - Applier, Composition, Recomposer (0) | 2025.03.30 |
