Jetpack Compose Internals를 참고하여 작성하였습니다
Compose UI에서의 측정 (Measuring in Compose UI)
실제 측정이 어떻게 이뤄지는가
모든 LayoutNode는 Owner를 통해 재측정을 요청할 수 있다
요청 시 Owner(뷰)는 "dirty"로 표시(invalidate)되며 노드는 재측정 및 재배치할 노드 목록에 추가된다
다음 그리기 시점에 AndroidComposeView의 dispatchDraw 함수가 호출된다
AndroidComposeView는 재측정 및 재배치할 노드 목록을 순회하고 해당 작업 수행
재측정 및 재배치가 예정된 노드에 적용되는 3가지 단계
- 노드가 재측정이 필요한지 확인 후 재측정 수행
- 측정 후, 재배치가 필요한지 확인 후 재배치 수행
- 모든 노드에 대해 연기된 측정 요청이 있는지 확인하고 이 노드들의 재측정 예약 (1단계로 다시)
각 노드 측정시 외부 LayoutNodeWrapper에 위임
Wrapper를 통해 측정 과정이 어떻게 이루어지는지 알 수 있다
internal fun insertAt(index: Int, instance: LayoutNode) {
...
// 노드가 삽입될 때 노드의 외부 래퍼가 현재 노드(새로운 부모)의 내부 래퍼에 의해 감싸진다
instance.outerLayoutNodeWrapper.wrappedBy = innerLayoutNodeWrapper
...
}
각 LayoutNode는 외부와 내부의 LayoutNodeWrapper를 가지고 있다
외부 래퍼는 현재 노드의 측정과 그리기를 담당
내부 래퍼는 자식들에 대해 동일한 작업 수행
하지만 노드에는 측정에 영향을 줄 수 있는 modifier가 적용될 수 있기에 불완전하다
Modifier는 상태가 없기에 상태를 유지하기 위해 래퍼가 필요하다
LayoutNode는 내부/외부 래퍼 뿐만 아니라 modifier 각각에 대한 래퍼를 가진다
래퍼는 연쇄적으로 연결되어 있고 순서대로 적용된다
래퍼가 서로 연결되어 있는 방식
- 부모 LayoutNode는 해당 노드의 measurePolicy를 사용해 모든 자식의 외부 래퍼를 측정
- 각 자식의 외부 래퍼는 체인의 첫 번째 modifier를 wrap
- 해당 modifier는 다음 modifier wrap
- modifier 수많큼 순차적으로 wrap
- 마지막 modifier는 내부 래퍼를 wrap
- 내부 래퍼는 현재 노드의 measurePolicy를 사용해 각 자식의 외부 래퍼 측정
노드를 측정하는 동안, 측정 람다(MeasurePolicy) 내 모든 변경 가능한 상태 읽기가 기록된다
측정 정책은 외부에서 전달되며 Compose State에 자유롭게 의존할 수 있다
측정 후, 이전 크기와 현재 측정된 크기를 비교해 변경될 경우 부모에게 재측정 요청
측정 정책 (Measuring policies)
@UiComposable
@Composable
inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy // 측정 정책
) {
...
ReusableComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update = {
// 부모가 아닌 노드에 직접 설정된 측정 정책을 사용
set(measurePolicy, SetMeasurePolicy)
...
},
skippableUpdate = materializerOf(modifier),
content = content
)
}
예시 1) Spacer Composable
@Composable
@NonRestartableComposable
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) {}
}
}
}
예시 2) Box Composable
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
// 측정 정책
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { BoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
@PublishedApi
@Composable
internal fun rememberBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean
): MeasurePolicy = if (alignment == Alignment.TopStart && !propagateMinConstraints) {
DefaultBoxMeasurePolicy
} else {
remember(alignment, propagateMinConstraints) {
BoxMeasurePolicy(alignment, propagateMinConstraints)
}
}
BoxMeasurePolicy
private data class BoxMeasurePolicy(
private val alignment: Alignment,
private val propagateMinConstraints: Boolean
) : MeasurePolicy {
// 측정 정책 구현체
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
// Box에 자식이 없는 경우 부모나 modifier에 의해 부여된 최소 너비와 높이에 맞추어 크기 조정
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight
) {}
}
// 자식에게 전파되도록(true)이면 부여된 제약 조건 적용하고
// 아닐 경우 최소 너비, 높이 제한을 제거하고 자식들이 이에 대해 결정할 수 있도록
val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}
/* 자식이 하나인지 아닌지 체크 */
if (measurables.size == 1) {
val measurable = measurables[0]
...
// 부모 크기에 맞추도록 설정되지 않는 경우 (Modifier.fillMaxSize()),
// 감싸고 있는 컨텐츠에 맞게 크기 조정
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
}
// Box가 부모 크기와 일치하도록 설정되면,
// 너비와 높이가 상위 제약 조건에 의해 적용된 최소너비, 높이와 동일한 값으로 설정
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)
// 부모 크기에 맞추지 않도록 설정된 모든 자식을 측정하는 것으로 시작
var hasMatchParentSizeChildren = false
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
// 모든 자식들 순회
// 자식들이 부여된 최소 제약 조건을 초과할 경우 Box를 이에 맞게 조정하거나, 최소 제약 조건을 크기로 설정
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
}
}
// 부모 크기와 일치하는 모든 자식들이 계산된 제약 조건을 사용해 측정된다
if (hasMatchParentSizeChildren) {
// The infinity check is needed for default intrinsic measurements.
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 목록에 추가
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)
}
}
}
}
고유 크기 측정 (Intrinsic measurements)
MeasurePolicy는 레이아웃의 고유 크기를 계산하는 함수를 포함한다
고유 크기란, 제약 조건이 없을 때 레이아웃의 추정 크기를 의미
측정(measure) 단계 이전에 자식의 추정 크기를 알아야 할 때 유용하다
MeasurePolicy
@Stable
@JvmDefaultWithCompatibility
fun interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
/* 반대 치수를 제공해 계산 */
// 주어진 높이에 대한 minInstrinsicWidth
// 특정 높이에 대해 레이아웃이 가질 수 있는 최소 너비 제공
fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
...
return layoutResult.width
}
// 주어진 너비에 대한 minInstrinsicHeight
// 특정 너비에 대해 레이아웃이 가질 수 있는 최소 높이 제공
fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
...
return layoutResult.height
}
// 주어진 높이에 대한 maxIntrinsicWidth
// 더 늘려도 최소 고유 높이가 감소하지 않는 최소 너비를 제공
fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
...
return layoutResult.width
}
// 주어진 너비에 대한 maxInstrinsicHeight
// 더 늘려도 최소 본질적 너비가 감소하지 않는 최소 높이를 제공
fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
...
return layoutResult.height
}
}
예시) Modifier.width(intrinsicSize: IntrinsicSize)
IntrinsicSize.Max일 경우 최소 고유 너비를 최대 고유 너비와 일치시키기 위해 덮어쓰고, 들어오는 maxHeight 제약 조건에 대해 가능한 최대 고유 너비와 일치하도록 컨텐츠 제약 조건을 고정한다
@Stable
fun Modifier.width(intrinsicSize: IntrinsicSize) = this then IntrinsicWidthElement(
width = intrinsicSize,
enforceIncoming = true,
inspectorInfo = debugInspectorInfo {
name = "width"
properties["intrinsicSize"] = intrinsicSize
}
)
enum class IntrinsicSize { Min, Max }
private class IntrinsicWidthElement(
val width: IntrinsicSize,
val enforceIncoming: Boolean,
val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<IntrinsicWidthNode>() {
override fun create() = IntrinsicWidthNode(width, enforceIncoming)
...
}
private class IntrinsicWidthNode(
var width: IntrinsicSize,
override var enforceIncoming: Boolean
) : IntrinsicSizeModifier() {
// IntrinsicSize 타입에 따라 분기
override fun MeasureScope.calculateContentConstraints(
measurable: Measurable,
constraints: Constraints
): Constraints {
var measuredWidth = if (width == IntrinsicSize.Min) {
measurable.minIntrinsicWidth(constraints.maxHeight)
} else {
measurable.maxIntrinsicWidth(constraints.maxHeight)
}
if (measuredWidth < 0) { measuredWidth = 0 }
return Constraints.fixedWidth(measuredWidth)
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = if (width == IntrinsicSize.Min) measurable.minIntrinsicWidth(height) else
measurable.maxIntrinsicWidth(height)
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = if (width == IntrinsicSize.Min) measurable.minIntrinsicWidth(height) else
measurable.maxIntrinsicWidth(height)
}
그렇다면, 사용자 관점에서 modifier를 사용할 때 UI는 어떻게 보이는 것일까
예시) DropdownMenuContent를 통해 알아보자
Column의 너비를 모든 자식의 최대 고유 너비와 일치하도록 설정
@Composable
fun DropdownMenuContent(modifier: Modifier = Modifier) {
...
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticlaPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
content = content
) { ... }
}
'Compose' 카테고리의 다른 글
[ComposeInternals] 컴포즈 UI (Compose UI) - (4) Modifier (0) | 2025.04.13 |
---|---|
[ComposeInternals] 컴포즈 UI (Compose UI) - (3) Layout Constraints (제약 조건) (0) | 2025.04.13 |
[ComposeInternals] 컴포즈 UI (Compose UI) - (1) (0) | 2025.04.03 |
[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (3) Recomposer (0) | 2025.03.30 |
[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (2) Composition (0) | 2025.03.30 |