Android Compose Modifier에 대해서(feat. Custom Modifier 만들기)

dev_yo·2026년 1월 8일
post-thumbnail

안녕하세요! 👋
오늘은 Compose에서 빠질 수 없는 Modifier 에 대해서 정리해보려고 합니다!

1. Modifier란?

ModifierComposable꾸미거나 확장하는 표준 방법으로 크기/레이아웃/동작/외형을 바꾸고, 접근성 정보나 입력 처리까지 붙일 수 있는 표준 Kotlin객체 입니다.

Modifier 는 기본적으로 체이닝 구조를 가지고 있기때문에 속성을 지정하는 순서가 레이아웃과 그리기에 영향을 미칠 수 있습니다. (각 함수는 이전 함수에서 반환한 Modifier를 변경하므로)

아래 예시를 통해 자세하게 살펴 보겠습니다!

@Composable
fun ArtistCard(/*...*/) {
    val padding = 16.dp
    Column(
        Modifier
            .clickable(onClick = onClick)
            .padding(padding)
            .fillMaxWidth()
    ) {
        
    }
}

위 함수의 실행 결과는 clickable 의 뒤에 padding 이 적용되어 패딩을 포함하여 클릭 영역으로 지정 됩니다.

@Composable
fun ArtistCard(/*...*/) {
    val padding = 16.dp
    Column(
        Modifier
            .padding(padding)
            .clickable(onClick = onClick)
            .fillMaxWidth()
    ) {
        
    }
}

반면에 위 함수의 실행 결과는 padding 이 먼저 적용되어 패딩 영역을 제외하고 클릭 영역이 설정 됩니다.


2. Modifier 재사용하기

공식 문서에서는 Modifier 체인을 만들고 추출하여 재사용할 수 있다고 말하고 있습니다. 어떤 상황에서 재사용을 하고 재사용 권장사항이 뭔지 알아보겠습니다!

1. 자주 변경되는 상태를 관찰할 때 Modifier 추출 및 재사용

@Composable
fun LoadingWheelAnimation() {
    val animatedState = animateFloatAsState(/*...*/)

    LoadingWheel(
        modifier = Modifier
            .padding(12.dp)
            .background(Color.Gray),
        animatedState = animatedState
    )
}

애니메이션 상태나 scrollState 처럼 컴포저블 내에서 자주 변경되는 상태를 관찰할 때 리컴포지션이 상당히 많이 발생할 수 있는데, 이때 잠재적으로 매 프레임마다 Modifier 가 재할당 될 수 있다고 합니다.

이런 경우 아래와 같이 Modifier 를 추출하여 재사용 하도록 권장하고 있습니다.

val reusableModifier = Modifier
    .padding(12.dp)
    .background(Color.Gray)

@Composable
fun LoadingWheelAnimation() {
    val animatedState = animateFloatAsState(/*...*/)

    LoadingWheel(
        modifier = reusableModifier,
        animatedState = animatedState
    )
}

2. 범위가 지정되지 않은 Modifier 추출 및 재사용

범위가 지정되지 않은 Modifier 의 경우 컴포저블 외부에서 변수로 추출하여 재사용 할 수 있습니다. 여기서 범의가 지정 되지 않았다는것은 scope 를 의미합니다! (BoxScope , ColumnScope , RowSceop 등)

val reusableItemModifier = Modifier
    .padding(bottom = 12.dp)
    .size(216.dp)
    .clip(CircleShape)

@Composable
private fun AuthorList(authors: List<Author>) {
    LazyColumn {
        items(authors) {
            AsyncImage(
                modifier = reusableItemModifier,
            )
        }
    }
}

대부분 Lazy 레이아웃에서 동일한 Modifier를 할당할때 유용하게 쓰입니다. 이걸로 엄청난 성능 이득을 본다고는 할 수 없지만, 아이템 갯수가 매우 많고 스크롤 시에 잦은 리컴포지션이 발생하는 화면에서 재할당에 대한 미세한 이득과 코드 일관성을 보장 할 수 있다는 점에서 재사용을 추천한다고 볼 수 있습니다.

3. 범위 지정 Modifier 추출 및 재사용

범위가 지정되어 있는 Modifier 의 경우 해당 Scope안에서만 사용할 수 있으며 가능한 가장 높은 수준의 Modifier 를 추출하여 사용해야 합니다.

Column(
	modifier = Modifier.fillMaxSize()
) {
    val reusableItemModifier = Modifier
        .padding(bottom = 12.dp)
        .align(Alignment.CenterHorizontally)
        .weight(1f)
    Text1(
        modifier = reusableItemModifier,
    )
    Text2(
        modifier = reusableItemModifier
    )
}

이렇게 Column 의 자식 컴포저블에게 동일한 속성을 주기 주어야 한다면 Modifier 를 추출하여 재사용하면 됩니다. 아울러, 추출된 Modifier 는 추가로 체이닝 하거나 then() 을 통해 다른 Modifier에 이어 붙일 수 있습니다.

Column(
	modifier = Modifier.fillMaxSize()
) {
    val reusableItemModifier = Modifier
        .padding(bottom = 12.dp)
        .align(Alignment.CenterHorizontally)
        .weight(1f)
    Text1(
        modifier = Modifier.then(reusableItemModifier),
    )
    Text2(
        modifier = reusableItemModifier.clickable {}
    )
}

3. Custom Modifier 만들기

Modifier 를 커스텀 하는 방법은 크게 3가지로 나뉘어져 있는데, 이것에 대해서 설명 드리도록 하겠습니다!

1. Modifier Factory

Modifier Factory를 사용하면 애니메이션 및 테마 설정과 같은 상위 수준
기능을 활성화하는 커스텀 Modifier 를 만들 수 있습니다.

Modifier 가 연계적으로 적용될 수 있다 라는 Modifier 의 특성을 활용하여 컴포저블 함수 어디에나 쉽게 사용될 수 있도록 합니다.

아래 함수는 press 시에 전달 받은 색으로 위를 덮는 동작을 하도록 한 커스텀 Modifier 입니다.

@Composable
fun Modifier.pressOverLay(
    interactionSource: InteractionSource,
    overlayColor: Color = Color.Red,
    pressedAlpha: Float = 0.12f,
): Modifier {
    val isPressed = interactionSource.collectIsPressedAsState().value
    return this.then(
        Modifier.drawWithContent {
            if (isPressed) {
                drawRect(overlayColor.copy(alpha = pressedAlpha))
            }
            drawContent()
        }
    )
}

커스텀 Modifier 를 만드는 가장 쉬운 방법이지만 Modifier Factory를 사용할때는 주의 사항이 있습니다.

1-1. CompositionLocal 값은 수정자 팩토리의 호출 사이트에서 확인됩니다.

@Composable
fun Modifier.myBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

@Composable
fun MyScreen() {
    CompositionLocalProvider(LocalContentColor provides Color.Green) {
        // 초록색으로 색이 provide됨
        val backgroundModifier = Modifier.myBackground()

        // 빨간색으로 LocalContentColor 새로 provide
        CompositionLocalProvider(LocalContentColor provides Color.Red) {

            // 빨간색 배경이 아닌 초록색 배경을 가짐
            Box(modifier = backgroundModifier)
        }
    }
}

위 코드에서 설명한 바와 같이 Modifier Factory 에서 CompositionLocal 은 사용되는 위치가 아닌 생성된 컴포지션 트리의 값을 가져옵니다. 그렇기 때문에 재대로 사용하지 않으면 잘못된 결과가 나올 수 있습니다!

1-2 컴포저블 함수 수정자는 건너뛸 수 없음

반환 값이 있는 컴포저블 함수는 skip 되지 않습니다. Modifier Factorty는 반환값을 가진 컴포저블 함수이므로 skip되지 않고 그말은 즉, Modifier 함수가 모든 리컴포지션에서 호출되므로 자주 리컴포지션되는 경우 비용이 많이 들 수 있습니다.

2. composed 함수

Compose 초창기 버전에서부터 커스텀 Modifier를 만드는 데 권장되었지만, 현재는 성능 문제로 인해 권장되는 방법은 아니라고 합니다! 위에서 살펴본 Modifie Factory 달리 composed {} 는 컴포지션 내에서 초기화 및 업데이트될 때 추가적인 리컴포지션 및 오버헤드를 유발합니다.

fun Modifier.pressOverlayComposed(
    interactionSource: InteractionSource,
    overlayColor: Color = Color.Red,
    pressedAlpha: Float = 0.12f,
): Modifier = composed {
    val isPressed = interactionSource.collectIsPressedAsState().value
    drawWithContent {
        if (isPressed) {
            drawRect(overlayColor.copy(alpha = pressedAlpha))
        }
        drawContent()
    }
}

3. Modifier.Node

Modifier.Node 는 효율성과 생명주기 관리 기능으로 인해 커스텀 Modifier 를 구현할 때 가장 선호되는 방법입니다. 개발자가 성능을 유지하면서 레이아웃, 드로잉 및 상호 작용에 대한 저수준 제어가 있는 Modifier를 정의할 수 있습니다.

3-1. Modifier.Node

Modifier의 동작을 정의합니다.

private class PressOverlayNode(
    var interactionSource: InteractionSource,
    var overlayColor: Color,
    var pressedAlpha: Float,
) : Modifier.Node(), DrawModifierNode {

    private var pressed = false
    private var collectJob: Job? = null

    override fun onAttach() {
        restartCollect()
    }

    override fun onDetach() {
        collectJob?.cancel()
        collectJob = null
    }

    fun restartCollect() {
        collectJob?.cancel()
        collectJob = coroutineScope.launch {
            interactionSource.interactions.collect { interaction: Interaction ->
                when (interaction) {
                    is PressInteraction.Press -> {
                        pressed = true
                        invalidateDraw()
                    }

                    is PressInteraction.Release,
                    is PressInteraction.Cancel -> {
                        pressed = false
                        invalidateDraw()
                    }
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        if (pressed) {
            drawRect(overlayColor.copy(alpha = pressedAlpha))
        }
        drawContent()
    }
}

3-2. ModifierNodeElement

노드를 생성하거나 업데이트하기 위한 상태를 저장하지 않는 팩토리를 제공합니다.

private data class PressOverlayElement(
    val interactionSource: InteractionSource,
    val overlayColor: Color,
    val pressedAlpha: Float,
) : ModifierNodeElement<PressOverlayNode>() {

    override fun create() = PressOverlayNode(
        interactionSource = interactionSource,
        overlayColor = overlayColor,
        pressedAlpha = pressedAlpha,
    )

    override fun update(node: PressOverlayNode) {
        val sourceChanged = node.interactionSource != interactionSource
        node.interactionSource = interactionSource
        node.overlayColor = overlayColor
        node.pressedAlpha = pressedAlpha

        // 파라미터 변경 시 필요한 invalidate만
        node.invalidateDraw()

        // InteractionSource가 바뀌면 collect 재구독
        if (sourceChanged) node.restartCollect()
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "pressOverlayNode"
        properties["overlayColor"] = overlayColor
        properties["pressedAlpha"] = pressedAlpha
        properties["interactionSource"] = interactionSource
    }
}

3-3 Modifier Factory

UI에서 사용할 Modifier에 대한 익스텐션을 정의합니다.

fun Modifier.pressOverlayNode(
    interactionSource: InteractionSource,
    overlayColor: Color = Color.Red,
    pressedAlpha: Float = 0.12f,
): Modifier = this.then(
    PressOverlayElement(
        interactionSource = interactionSource,
        overlayColor = overlayColor,
        pressedAlpha = pressedAlpha
    )
)

안드로이드 공식 문서에서는 어지간하면 기존 Modifier 조합 혹은 Modifier Factory를 통해 커스텀 Modifier 를 만들고, 그걸로 불가능하거나 너무 많은 비용이 발생할 경우에만 Modifier.Node를 활용하라고 권장하고 있습니다!

4. 마치며

포스팅을 쓰면서 흔히 쓰던 Modifier에 대해서 좀 더 심도있게 공부하는 계기가 된것 같습니다! 성능이 좋다고 무조건 Modifier.Node만 쓰기보다는 각자의 상황에 맞게 사용하면 될것 같습니다!

오늘은 이상으로 포스팅 마치겠습니다!
오늘도 즐코하세요 🙇‍♂️

📚 Reference
Android Manifest Interview <저자: 엄재웅(skydoves)>

Android Developers (Compose Modifiers)

Android Developers (Create Custom Modifiers)

profile
어제보다 발전한 오늘이 되고픈 🧑🏻‍💻

0개의 댓글