Modifier.composed에서 Modifier.Node로 이전

Modifier.composed는 수정자에서 컴포지션 요소에 액세스할 수 있도록 Compose 1.0에서 도입되었습니다. 예를 들어 주요 사용 사례 중 하나는 로컬 상태를 기억하고 Modifier.composed 팩토리의 다른 수정자와 공유하는 상태 저장 수정자를 만드는 것입니다.

// ❌ BAD: Using Modifier.composed is no longer recommended
fun Modifier.pressScale(
    pressedScale: Float = 0.95f,
    onClick: () -> Unit
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "pressScale"
        properties["pressedScale"] = pressedScale
    }
) {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()
    val scale by animateFloatAsState(
        targetValue = if (isPressed) pressedScale else 1f,
        animationSpec = spring(),
        label = "pressScale"
    )

    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
}

Modifier.Node는 수정자 내에서 상태가 관리되는 방식을 개선하므로 Modifier.composed 대신 Modifier.Node를 사용하세요. Modifier.Node는 LayoutNode에 적용된 Modifier.Element당 한 번 생성되는 수명이 긴 스테이트풀 객체이며, 매번 패스에서 컴포지션을 통해 다시 구체화되는 대신 리컴포지션에서 유지됩니다. Modifier.Node를 설계한 이유와 방법에 관한 자세한 내용은 Compose 수정자 심층 분석을 참고하세요.

이 문서에서는 Modifier.composed에서 Modifier.Node로 이전하는 방법을 설명합니다. 이 API의 일반적인 사용에 관한 자세한 내용은 Modifier.Node를 사용하여 맞춤 수정자 동작 구현을 참고하세요.

Modifier.Node의 성능 이점

Modifier.composed를 사용하면 다음과 같은 몇 가지 기본적인 성능 병목 현상이 발생합니다.

  • 상태 관리 오버헤드: 이 범위에서 상태를 관리하려면 remember 호출과 스냅샷 상태 객체가 필요하므로 불필요한 컴포지션 그룹으로 슬롯 테이블이 부풀려지고 메모리 압력이 증가합니다.
  • 비싼 수명 주기 액세스: 수정자의 수명 주기에 액세스하려면 DisposableEffect와 같은 효과를 사용해야 하며, 이로 인해 간단한 사용 사례에 필요한 작업이 빠르게 증가합니다.
  • 건너뛸 수 없음: composed에 전달된 람다가 Modifier를 반환하므로 Compose 컴파일러가 건너뛸 수 있는 것으로 표시할 수 없어 레이아웃이 재구성될 때마다 다시 실행해야 합니다.
  • 메모이제이션 및 동등성 깨짐: 외부 확장 함수 자체가 @Composable이 아니므로 컴파일러가 내부 람다를 메모이제이션할 수 없어 호출할 때마다 새로운 람다가 할당됩니다. 이 메모이제이션 부족은 ComposedModifier가 참조로 람다를 비교하므로 수정자 동등성 (equals)을 직접적으로 깨뜨립니다. 따라서 매개변수가 정적인 경우에도 Compose는 모든 프레임에서 수정자가 변경된 것으로 처리합니다.
  • 스마트 변경 전파 없음: 최상위 컴포저블 매개변수 추적 없이는 스마트 변경 전파를 위해 새 입력과 이전 입력을 비교할 방법이 없습니다.

전반적으로 Modifier.composed API 모양은 비용이 많이 드는 코드를 작성하도록 유도하고 Compose 런타임이 추가 수정자 최적화를 적용하지 못하도록 합니다.

핵심 마이그레이션 단계

다음 예는 Modifier.composed로 구현된 일반적인 맞춤 수정자를 보여줍니다. 자세한 내용은 Modifier.Node를 사용하여 맞춤 수정자 동작 구현을 참고하세요.

fun Modifier.underline(
    color: Color,
    thickness: Dp = 2.dp,
    animationDurationMillis: Int = 300
): Modifier = composed {
    val density = LocalDensity.current
    val strokePx = with(density) { thickness.toPx() }

    // Drives how much of the underline is drawn: 0f -> 1f
    val progress = remember { Animatable(0f) }

    LaunchedEffect(color, thickness) {
        progress.snapTo(0f)
        progress.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = animationDurationMillis)
        )
    }

    drawBehind {
        val y = size.height - strokePx / 2
        drawLine(
            color = color,
            start = Offset(0f, y),
            end = Offset(size.width * progress.value, y),
            strokeWidth = strokePx
        )
    }
}

  1. 맞춤 Modifier.Node (또는 DelegatingNode)를 만듭니다.

    private class UnderlineNode(
        private var color: Color,
        private var thickness: Dp,
        private var animationDurationMillis: Int
    ) : Modifier.Node() {
        fun update(color: Color, thickness: Dp, durationMillis: Int) {
        }
    }

  2. 맞춤 수정자가 필요한 사항에 따라 Modifier.Node의 보조 API를 하나 이상 구현합니다 (예: 포인터 입력 API에 액세스해야 하는 경우 PointerInputModifierNode).

    private class UnderlineNode(
        private var color: Color,
        private var thickness: Dp,
        private var animationDurationMillis: Int
    ) : Modifier.Node(), DrawModifierNode {
    
        private val progress = Animatable(0f)
        private var animationJob: Job? = null
    
        override fun onAttach() {
            restartAnimation()
        }
    
        fun update(color: Color, thickness: Dp, durationMillis: Int) {
            val needsRestart = this.color != color || this.thickness != thickness
            this.color = color
            this.thickness = thickness
            this.animationDurationMillis = durationMillis
            if (needsRestart) restartAnimation()
        }
    
        private fun restartAnimation() {
            animationJob?.cancel()
            animationJob = coroutineScope.launch {
                progress.snapTo(0f)
                progress.animateTo(1f, tween(animationDurationMillis))
            }
        }
    
        override fun ContentDrawScope.draw() {
            val strokePx = thickness.toPx()
            val y = size.height - strokePx / 2
            drawLine(
                color = color,
                start = Offset(0f, y),
                end = Offset(size.width * progress.value, y),
                strokeWidth = strokePx
            )
            drawContent()
        }
    }

  3. 커스텀 노드를 만들고 업데이트하는 ModifierNodeElement를 만듭니다.

    private class UnderlineElement(
        private val color: Color,
        private val thickness: Dp,
        private val animationDurationMillis: Int
    ) : ModifierNodeElement<UnderlineNode>() {
    
        override fun create() = UnderlineNode(color, thickness, animationDurationMillis)
    
        override fun update(node: UnderlineNode) {
            node.update(color, thickness, animationDurationMillis)
        }
    
        override fun InspectorInfo.inspectableProperties() {
            name = "underline"
            properties["color"] = color
            properties["thickness"] = thickness
            properties["animationDurationMillis"] = animationDurationMillis
        }
    
        override fun hashCode(): Int {
            var result = color.hashCode()
            result = 31 * result + thickness.hashCode()
            result = 31 * result + animationDurationMillis.hashCode()
            return result
        }
    
        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            val otherElement = other as? UnderlineElement ?: return false
            return color == otherElement.color &&
                thickness == otherElement.thickness &&
                animationDurationMillis == otherElement.animationDurationMillis
        }
    }

  4. ModifierNodeElement를 가리키도록 수정자 팩토리를 업데이트합니다.

    fun Modifier.underline(
        color: Color,
        thickness: Dp = 2.dp,
        animationDurationMillis: Int = 300
    ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)

일반적인 이전 레시피

다음 레시피는 Modifier.composed에서 Modifier.Node 또는 @Composable 수정자 팩토리로 일반적인 패턴을 이전하는 방법을 보여줍니다.

CompositionLocal 액세스

패턴: LocalDensity, Theme, LocalView과 같은 단일 CompositionLocal를 읽습니다.

마이그레이션 경로: 수정자를 @Composable로 표시합니다. composed 수정자와 @Composable 수정자 팩토리를 사용하여 CompositionLocal에 액세스하는 것에는 시맨틱 차이가 있습니다. @Composable 팩토리의 경우 CompositionLocal 값이 수정자 팩토리의 호출 사이트에서 확인됩니다. 원하는 동작이 아닌 경우 CompositionLocalConsumerModifierNode를 사용하여 CompositionLocal를 읽는 맞춤 Modifier.Node 구현을 사용하세요.

자세한 내용은 컴포저블 수정자 팩토리를 사용하여 맞춤 수정자 만들기를 참고하세요.

// ❌ BAD: Using Modifier.composed to read a single CompositionLocal
fun Modifier.themedContainerBorder(): Modifier =
    composed {
        Modifier.border(
            BorderStroke(
                width = 2.dp,
                color = LocalColorScheme.current.primaryColor,
            )
        )
            .clipToBounds()
    }

// ✅ GOOD: If the modifier is @Composable, it should be able to access the locals.
@Composable
fun Modifier.themedContainerBorder() =
    this then Modifier.border(
        BorderStroke(
            width = 2.dp,
            color = MyTheme.mainColor,
        )
    )
        .clipToBounds()

패턴: 후속 수정자에 적용될 수 있는 CompositionLocal을 읽습니다.

이전 경로: CompositionLocalConsumerModifierNode를 구현하고 모든 수정자 기능을 결합하는 맞춤 Modifier.Node를 만듭니다.

// ❌ BAD: Using Modifier.composed to read a CompositionLocal then using it in another modifier.
fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = composed {
    // Reading LocalThemePadding.current.small (CompositionLocal)
    val extraPadding = LocalThemePadding.current.small
    Modifier.padding(basePadding + extraPadding)
}

// ✅ GOOD: A custom Modifier that combines the capabilities of both (layout and composition local reader) modifiers.
fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier =
    this.then(AdaptivePaddingElement(basePadding))

private data class AdaptivePaddingElement(
    val basePadding: Dp,
) : ModifierNodeElement<AdaptivePaddingNode>() {
    override fun create() = AdaptivePaddingNode(basePadding)

    override fun update(node: AdaptivePaddingNode) {
        node.basePadding = basePadding
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "adaptiveAccessibilityPadding"
        properties["basePadding"] = basePadding
    }
}

private class AdaptivePaddingNode(
    var basePadding: Dp,
) : Modifier.Node(), LayoutModifierNode, CompositionLocalConsumerModifierNode {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val extraPadding = currentValueOf(LocalThemePadding).small
        val total = (basePadding + extraPadding).roundToPx()

        val horizontal = total * 2
        val vertical = total * 2

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)

        return layout(width, height) {
            placeable.place(total, total)
        }
    }
}

레이아웃이 아닌 컴포저블 함수에 액세스

패턴: 수정자는 @Composable로 주석이 지정되고 객체 (예: colorResource 또는 ScrollableDefaults.flingBehavior)를 반환하는 함수에 액세스해야 합니다.

이전 경로: 수정자에 @Composable 주석을 답니다.

// ❌ BAD: Using Modifier.composed to access a composable function such as colorResource
fun Modifier.niceBackground() = composed {
    // Reading composable function colorResource
    val gradientColor1 = colorResource(R.color.my_special_color)
    background(color = gradientColor1, shape = CircleShape)
}

// ✅ GOOD: A modifier can be annotation with @Composable to reference composable functions.
@Composable // Modifier can be Composable itself.
private fun Modifier.niceBackground(): Modifier {
    val gradientColor1 = colorResource(R.color.my_special_color)
    return this.background(color = gradientColor1, shape = CircleShape)
}

코루틴 범위 액세스

패턴: Modifier.composed는 rememberCoroutineScope를 실행하여 코루틴을 실행하기 위한 coroutineScope 객체에 액세스하는 데 사용됩니다.

이전 경로: 수정자 수명 주기 (예: Modifier.composed 내의 rememberCoroutineScope)에 연결된 coroutineScope 속성이 있는 맞춤 Modifier.Node를 사용합니다.

// ❌ BAD: Using Modifier.composed to get access to a coroutine scope.
fun Modifier.onClickAsyncComposed(onClick: suspend () -> Unit): Modifier =
    composed {
        val scope = rememberCoroutineScope()
        Modifier.pointerInput(onClick) {
            detectTapGestures {
                // Needs a coroutine scope to launch suspend lambda.
                scope.launch {
                    onClick()
                }
            }
        }
    }

// ✅ GOOD: A custom Modifier.Node has a scoped (modifier lifecycle) coroutineScope that can be used to launch async work.
fun Modifier.onClickAsync(onClick: suspend () -> Unit): Modifier =
    this.then(OnClickAsyncElement(onClick))

private data class OnClickAsyncElement(val onClick: suspend () -> Unit) :
    ModifierNodeElement<OnClickAsyncNode>() {
    override fun create(): OnClickAsyncNode = OnClickAsyncNode(onClick)

    override fun update(node: OnClickAsyncNode) {
        node.update(onClick)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "onClickAsync"
        properties["onClick"] = onClick
    }
}

private class OnClickAsyncNode(private var onClick: suspend () -> Unit) :
    DelegatingNode(), PointerInputModifierNode {

    private val pointerInputNode =
        delegate(
            SuspendingPointerInputModifierNode {
                detectTapGestures {
                    // Modifier.Node provides `coroutineScope` directly.
                    coroutineScope.launch { onClick() }
                }
            }
        )

    fun update(onClick: suspend () -> Unit) {
        if (this.onClick != onClick) {
            this.onClick = onClick
            pointerInputNode.resetPointerInputHandler()
        }
    }

    override fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize,
    ) {
        pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
    }

    override fun onCancelPointerInput() {
        pointerInputNode.onCancelPointerInput()
    }
}

상태 기억하기

패턴: Modifier.composed에서 remember를 사용하여 리컴포지션 간에 상태를 저장합니다.

이전 경로: Modifier.Node는 동일한 방식으로 상태를 유지하도록 빌드되었습니다. 더 명확한 수명 주기를 갖는 다른 클래스 속성과 마찬가지로 상태는 인스턴스 내에 유지될 수 있습니다.

// ❌ BAD: Using Modifier.composed to make the modifier stateful.
fun Modifier.tapCountHighlightComposed(colors: List<Color>): Modifier = composed {
    // 1. Must use `remember` so `tapCount` isn't reset to 0 on every recomposition
    var tapCount by remember { mutableIntStateOf(0) }

    Modifier
        .pointerInput(colors) { detectTapGestures { tapCount++ } }
        .drawBehind { drawRect(colors[tapCount % colors.size]) }
}

// ✅ GOOD: Modifier.Node is the recommended way of creating stateful modifiers.
fun Modifier.tapCountHighlight(colors: List<Color>): Modifier =
    this then TapCountHighlightElement(colors)

private data class TapCountHighlightElement(
    val colors: List<Color>,
) : ModifierNodeElement<TapCountHighlightNode>() {
    override fun create() = TapCountHighlightNode(colors)

    override fun update(node: TapCountHighlightNode) {
        node.updateColors(colors)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "tapCountHighlight"
        properties["colors"] = colors
    }
}

private class TapCountHighlightNode(
    private var colors: List<Color>,
) : DelegatingNode(), DrawModifierNode {
    private var tapCount = 0 // Stateful modifier, this property will survive recompositions since Modifier.Nodes are held in the modifier tree.

    private val pointerInputNode = delegate(
        SuspendingPointerInputModifierNode {
            detectTapGestures {
                tapCount++
                invalidateDraw()
            }
        }
    )

    override fun ContentDrawScope.draw() {
        drawRect(colors[tapCount % colors.size])
        drawContent()
    }

    fun updateColors(colors: List<Color>) {
        this.colors = colors
        invalidateDraw()
    }
}

효과 사용하기

패턴: 효과를 사용하여 컴포지션 수명 주기에 연결된 작업을 실행합니다 (예: Modifier.composed이 컴포지션에 진입하거나 컴포지션에서 종료될 때).

이전 경로: Modifier.Node에는 동일한 작업을 실행하는 데 사용할 수 있는 명확한 수명 주기 콜백이 있습니다. 예를 들어 LaunchedEffect는 일반적으로 Modifier.Node onAttach 메서드 내에서 coroutineScope를 사용하여 대체할 수 있습니다.

// ❌ BAD: Using Modifier.composed to launch/run an effect.
fun Modifier.logImpressionComposed(
    targetId: String,
    onLog: suspend (targetId: String) -> Unit,
): Modifier =
    composed {
        // LaunchedEffect is tied to Composition lifecycle
        LaunchedEffect(targetId) { onLog(targetId) }
        this
    }

// ✅ GOOD: Modifier.Node has lifecycle callbacks (e.g onAttach, onDetach) that can be used to emulate effects behaviors.
fun Modifier.logImpression(targetId: String, onLog: suspend (targetId: String) -> Unit): Modifier =
    this.then(LogImpressionElement(targetId, onLog))

private data class LogImpressionElement(
    val targetId: String,
    val onLog: suspend (targetId: String) -> Unit,
) : ModifierNodeElement<LogImpressionNode>() {
    override fun create(): LogImpressionNode = LogImpressionNode(targetId, onLog)

    override fun update(node: LogImpressionNode) {
        node.update(targetId, onLog)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "logImpression"
        properties["targetId"] = targetId
    }
}

private class LogImpressionNode(
    var targetId: String,
    var onLog: suspend (targetId: String) -> Unit,
) : Modifier.Node() {
    private var job: Job? = null

    override fun onAttach() {
        super.onAttach()
        runEffect() // Uses onAttach to track modifier lifecycle.
    }

    fun update(targetId: String, onLog: suspend (targetId: String) -> Unit) {
        // Re-run the effect if the key (`targetId`) changed
        if (this.targetId != targetId) {
            runEffect()
        }
        this.targetId = targetId
        this.onLog = onLog
    }

    private fun runEffect() {
        job?.cancel()
        job = coroutineScope.launch { onLog(targetId) }
    }
}

애니메이션 상태 유지

패턴: animate*AsState을 사용하는 Modifier.composed

이전 경로: animate*AsState는 수명 주기 콜백을 모니터링하고 Animatable 상태를 보유하는 맞춤 수정자로 분류할 수 있습니다.

// ❌ BAD: Using Modifier.composed to save an animation state.
fun Modifier.fadeInOnHoverComposed(isHovered: Boolean): Modifier =
    composed {
        val alpha by
            animateFloatAsState(
                targetValue = if (isHovered) 1f else 0.4f,
                animationSpec = tween(durationMillis = 300),
                label = "alphaAnimation",
            )

        Modifier.graphicsLayer { this.alpha = alpha }
    }

// ✅ GOOD: Animation state can be saved in Modifier.Node like other types of stateful implementations.
fun Modifier.fadeInOnHover(isHovered: Boolean): Modifier =
    this.then(FadeInOnHoverElement(isHovered))

private data class FadeInOnHoverElement(val isHovered: Boolean) :
    ModifierNodeElement<FadeInOnHoverNode>() {
    override fun create(): FadeInOnHoverNode = FadeInOnHoverNode(isHovered)

    override fun update(node: FadeInOnHoverNode) {
        node.update(isHovered)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "fadeInOnHover"
        properties["isHovered"] = isHovered
    }
}

private class FadeInOnHoverNode(var isHovered: Boolean) : Modifier.Node(), LayoutModifierNode {
    // 1. Persistent Animatable field on the Node instance
    private val alphaAnimatable = Animatable(if (isHovered) 1f else 0.4f)

    override fun onAttach() {
        super.onAttach()
        startAnimation(isHovered)
    }

    // 2. Trigger animation imperatively when `isHovered` argument changes
    fun update(isHovered: Boolean) {
        if (this.isHovered != isHovered) {
            this.isHovered = isHovered
            if (isAttached) {
                startAnimation(isHovered)
            }
        }
    }

    private fun startAnimation(hovered: Boolean) {
        val targetAlpha = if (hovered) 1f else 0.4f
        // Use Node's built-in coroutineScope
        coroutineScope.launch {
            alphaAnimatable.animateTo(
                targetValue = targetAlpha,
                animationSpec = tween(durationMillis = 300),
            )
        }
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            // Read current animation value during layout placement layer
            placeable.placeWithLayer(0, 0) { alpha = alphaAnimatable.value }
        }
    }
}