[Android] Modifier 이것저것 알아보기

신민준·2026년 2월 9일

Jetpack Compose

목록 보기
4/4

들어가며

오늘은 Manifest Android Interview를 읽어보며 Modifier에 대해 더 잘 알아보고자 한다.
Modifier를 깊게 파보기보다는 Modifier를 넓게 살펴보는 글로 여러 메서드가 어떻게 동작하는지를 간단하게 살펴볼 것이다.

requiredSize()는 어떻게 제약 조건을 무시할까?

requiredSize()는 부모의 제약 조건에 관계없이 고정된 크기를 적용하는 메서드이다.

그렇다면 size(), height() 같은 다른 크기 관련 메서드와 달리 어떻게 requiredSize()는 고정된 크기를 적용하는 걸까?

size()requiredSize()의 내부 구조를 통해 알아보자.

@Stable
fun Modifier.size(size: Dp) =
    this.then(
        SizeElement(
            minWidth = size,
            maxWidth = size,
            minHeight = size,
            maxHeight = size,
            enforceIncoming = true,
            inspectorInfo =
                debugInspectorInfo {
                    name = "size"
                    value = size
                },
        )
    )
@Stable
fun Modifier.requiredSize(size: Dp) =
    this.then(
        SizeElement(
            minWidth = size,
            maxWidth = size,
            minHeight = size,
            maxHeight = size,
            enforceIncoming = false,
            inspectorInfo =
                debugInspectorInfo {
                    name = "requiredSize"
                    value = size
                },
        )
    )

두 메서드에서 주목할 유의미한 차이는 enforceIncoming다.

size()enforceIncoming가 true이고 requiredSize()는 false인데 이것이 두 메서드의 차이를 만든다.

private class SizeElement(
    private val minWidth: Dp = Dp.Unspecified,
    private val minHeight: Dp = Dp.Unspecified,
    private val maxWidth: Dp = Dp.Unspecified,
    private val maxHeight: Dp = Dp.Unspecified,
    private val enforceIncoming: Boolean,
    private val inspectorInfo: InspectorInfo.() -> Unit,
) : ModifierNodeElement<SizeNode>() {
    override fun create(): SizeNode =
        SizeNode(
            minWidth = minWidth,
            minHeight = minHeight,
            maxWidth = maxWidth,
            maxHeight = maxHeight,
            enforceIncoming = enforceIncoming,
        )
        ...

SizeElement는 위와 같다. 이는 SizeNode를 생성하는데 이 SizeNode에 enforceIncoming 값이 전달된다.

private class SizeNode(
    var minWidth: Dp = Dp.Unspecified,
    var minHeight: Dp = Dp.Unspecified,
    var maxWidth: Dp = Dp.Unspecified,
    var maxHeight: Dp = Dp.Unspecified,
    var enforceIncoming: Boolean,
) : LayoutModifierNode, Modifier.Node() {
	...
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val wrappedConstraints =
            targetConstraints.let { targetConstraints ->
                if (enforceIncoming) {
                    constraints.constrain(targetConstraints)
                } else {
                    val resolvedMinWidth =
                        if (minWidth.isSpecified) {
                            targetConstraints.minWidth
                        } else {
                            constraints.minWidth.fastCoerceAtMost(targetConstraints.maxWidth)
                        }
                    val resolvedMaxWidth =
                        if (maxWidth.isSpecified) {
                            targetConstraints.maxWidth
                        } else {
                            constraints.maxWidth.fastCoerceAtLeast(targetConstraints.minWidth)
                        }
                    val resolvedMinHeight =
                        if (minHeight.isSpecified) {
                            targetConstraints.minHeight
                        } else {
                            constraints.minHeight.fastCoerceAtMost(targetConstraints.maxHeight)
                        }
                    val resolvedMaxHeight =
                        if (maxHeight.isSpecified) {
                            targetConstraints.maxHeight
                        } else {
                            constraints.maxHeight.fastCoerceAtLeast(targetConstraints.minHeight)
                        }
                    Constraints(
                        resolvedMinWidth,
                        resolvedMaxWidth,
                        resolvedMinHeight,
                        resolvedMaxHeight,
                    )
                }
            }
	...

이 코드를 보면 enforceIncoming가 true면 제약 조건을 적용하고 false면 제약 조건을 적용하지 않고 자신의 크기를 적용한다.

onGloballyPositioned는 어떻게 컴포넌트의 절대 좌표를 가져올까?

먼저 onGloballyPositioned는 아래와 같이 OnGloballyPositionedNode를 생성한다.

@Stable
fun Modifier.onGloballyPositioned(onGloballyPositioned: (LayoutCoordinates) -> Unit) =
    this then OnGloballyPositionedElement(onGloballyPositioned)

private class OnGloballyPositionedElement(val onGloballyPositioned: (LayoutCoordinates) -> Unit) :
    ModifierNodeElement<OnGloballyPositionedNode>() {
    override fun create(): OnGloballyPositionedNode {
        return OnGloballyPositionedNode(onGloballyPositioned)
    }
	...

OnGloballyPositionedNode는 다음과 같이 되어있다.

private class OnGloballyPositionedNode(var callback: (LayoutCoordinates) -> Unit) :
    Modifier.Node(), GlobalPositionAwareModifierNode {
    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
        callback(coordinates)
    }

onGloballyPositioned를 호출함으로서 coordinates 값을 람다에 전달하는데 이것은 LayoutNode.kt에서 호출하는 것을 확인할 수 있다.

internal class LayoutNode {
	...	
	internal fun dispatchOnPositionedCallbacks() {
        if (layoutState != Idle || layoutPending || measurePending || isDeactivated) {
            return // it hasn't yet been properly positioned, so don't make a call
        }
        if (!isPlaced) {
            return // it hasn't been placed, so don't make a call
        }
        nodes.headToTail(Nodes.GlobalPositionAware) {
            it.onGloballyPositioned(it.requireCoordinator(Nodes.GlobalPositionAware))
        }
    }	
    ...
}

컴포지션 단계중 Layout단계는 Measure와 Layout 단계로 나뉘는데 그중 Layout 단계가 마무리 될때 최종적으로 dispatchOnPositionedCallbacks()가 호출된다.

즉 최종 배치 마무리 단계에서 해당 컴포넌트의 좌표값을 호이스팅하게 되는 구조이다.

그럼 좌표값을 호이스팅하는 것을 알아봤으니 좌표값을 구하는 방법을 알아보자.

internal abstract class NodeCoordinator(override val layoutNode: LayoutNode) :
    LookaheadCapablePlaceable(), Measurable, LayoutCoordinates, OwnerScope {
	...
	@OptIn(ExperimentalComposeUiApi::class)
    override fun localToRoot(relativeToLocal: Offset): Offset {
        checkPrecondition(isAttached) { ExpectAttachedLayoutCoordinates }
        onCoordinatesUsed()
        var coordinator: NodeCoordinator? = this
        var position = relativeToLocal
        while (coordinator != null) {
            if (ComposeUiFlags.isRectManagerOffsetUsageFromLayoutCoordinatesEnabled) {
                val layoutNode = coordinator.layoutNode
                if (
                    coordinator === layoutNode.outerCoordinator &&
                        !layoutNode.hasPositionalLayerTransformationsInOffsetFromRoot
                ) {
                    val offsetFromRectList =
                        layoutNode.requireOwner().rectManager.getOffsetFromRectListFor(layoutNode)
                    if (offsetFromRectList != IntOffset.Max) {
                        return position + offsetFromRectList
                    }
                }
            }
            position = coordinator.toParentPosition(position)
            coordinator = coordinator.wrappedBy
        }
        return position
    }
    ...
    open fun toParentPosition(
        position: Offset,
        includeMotionFrameOfReference: Boolean = true,
    ): Offset {
        val layer = layer
        val targetPosition = layer?.mapOffset(position, inverse = false) ?: position
        return if (!includeMotionFrameOfReference && isPlacedUnderMotionFrameOfReference) {
            targetPosition
        } else {
            targetPosition + this.position
        }
    }
    ...
}

이 부분이 NodeCoordinator가 좌표를 구하는 방법이다.

localToRoot 메서드가 position 값을 반환하는데 이 position은 coordinator = coordinator.wrappedBy로 coordinator를 null이 될 때까지 계속 벗기면서 coordinator.toParentPosition(position)로 현재 position값과 부모 내의 상대적 위치값을 더해나가며 구한다.

즉,
1. 부모 내의 상대 위치 구하기
2. 래퍼 벗기기
3. 루트에 도달할 때 까지 반복

이다.

dispatchOnPositionedCallbacks()가 Layout 단계가 마무리 될 때 최종적으로 호출되기에 좌표를 첫 Layout 이후에 알게 된다.
그렇기에 이를 이용해 툴팁을 그리거나 할 경우 recomposition이 일어나게 된다.

Box나 Column 등에서 align이나 weight로 어떻게 부모 레이아웃에 자식의 데이터를 전달할까?

이것도 NodeCoordinator에서 확인할 수 있다.
이어서 마저 확인해보자.

NodeCoordinator는 parentData라는 부모에게 전달하는 데이터를 가지고 있다.

internal abstract class NodeCoordinator(override val layoutNode: LayoutNode) :
    LookaheadCapablePlaceable(), Measurable, LayoutCoordinates, OwnerScope {
    ...
    override val parentData: Any?
        get() {
            // NOTE: If you make changes to this getter, please check the generated bytecode to
            // ensure no extra allocation is made. See the note below.
            if (layoutNode.nodes.has(Nodes.ParentData)) {
                val thisNode = tail
                // NOTE: Keep this mutable variable scoped inside the if statement. When moved
                // to the outer scope of get(), this causes the compiler to generate a
                // Ref$ObjectRef instance on every call of this getter.
                var data: Any? = null
                layoutNode.nodes.tailToHead { node ->
                    if (node.isKind(Nodes.ParentData)) {
                        node.dispatchForKind(Nodes.ParentData) {
                            data = with(it) { layoutNode.density.modifyParentData(data) }
                        }
                    }
                    if (node === thisNode) return@tailToHead
                }
                return data
            }
            return null
        }
    ...
}

tailToHead는 코드상 가장 마지막에 쓴 Modifier부터 부모 방향으로 거슬러 올라가는 메서드로 트리의 tail에서 해당 coordinator가 담당하는 부분의 tail까지 올라오면서 data가 null부터 시작해서 정보를 계속 누적한다.

이것이 부모의 레이아웃에 닿으면 이 값을 통해 자식 레이아웃을 배치한다.
Column 코드를 예시로 살펴보면

internal data class ColumnMeasurePolicy(
    private val verticalArrangement: Arrangement.Vertical,
    private val horizontalAlignment: Alignment.Horizontal,
) : MeasurePolicy, RowColumnMeasurePolicy {
	...
    private fun getCrossAxisPosition(
        placeable: Placeable,
        parentData: RowColumnParentData?,
        crossAxisLayoutSize: Int,
        beforeCrossAxisAlignmentLine: Int,
        layoutDirection: LayoutDirection,
    ): Int {
        val childCrossAlignment = parentData?.crossAxisAlignment
        return childCrossAlignment?.align(
            size = crossAxisLayoutSize,
            layoutDirection = layoutDirection,
            placeable = placeable,
            beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine,
        ) ?: horizontalAlignment.align(placeable.width, crossAxisLayoutSize, layoutDirection)
    }
    ...
}

자신의 measurePolicy에서 해당 데이터가 자신이 사용하는 align인지를 확인하며 사용하게 된다.

마무리

Modifier의 이것저것을 알아보았다.
Compose로 개발하다보면 Modifier를 정말 많이 사용한다.
이걸로 조금은 친근해진 것 같긴한데 늘 어렵다.

profile
안드로이드 외길

0개의 댓글