
안녕하세요! 👋
오늘은 Compose에서 빠질 수 없는 Modifier 에 대해서 정리해보려고 합니다!
Modifier 는 Composable 을 꾸미거나 확장하는 표준 방법으로 크기/레이아웃/동작/외형을 바꾸고, 접근성 정보나 입력 처리까지 붙일 수 있는 표준 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 이 먼저 적용되어 패딩 영역을 제외하고 클릭 영역이 설정 됩니다.
공식 문서에서는 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
)
}
범위가 지정되지 않은 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를 할당할때 유용하게 쓰입니다. 이걸로 엄청난 성능 이득을 본다고는 할 수 없지만, 아이템 갯수가 매우 많고 스크롤 시에 잦은 리컴포지션이 발생하는 화면에서 재할당에 대한 미세한 이득과 코드 일관성을 보장 할 수 있다는 점에서 재사용을 추천한다고 볼 수 있습니다.
범위가 지정되어 있는 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 {}
)
}
Modifier 를 커스텀 하는 방법은 크게 3가지로 나뉘어져 있는데, 이것에 대해서 설명 드리도록 하겠습니다!
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를 사용할때는 주의 사항이 있습니다.
@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 은 사용되는 위치가 아닌 생성된 컴포지션 트리의 값을 가져옵니다. 그렇기 때문에 재대로 사용하지 않으면 잘못된 결과가 나올 수 있습니다!
반환 값이 있는 컴포저블 함수는 skip 되지 않습니다. Modifier Factorty는 반환값을 가진 컴포저블 함수이므로 skip되지 않고 그말은 즉, Modifier 함수가 모든 리컴포지션에서 호출되므로 자주 리컴포지션되는 경우 비용이 많이 들 수 있습니다.
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()
}
}
Modifier.Node 는 효율성과 생명주기 관리 기능으로 인해 커스텀 Modifier 를 구현할 때 가장 선호되는 방법입니다. 개발자가 성능을 유지하면서 레이아웃, 드로잉 및 상호 작용에 대한 저수준 제어가 있는 Modifier를 정의할 수 있습니다.
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()
}
}
노드를 생성하거나 업데이트하기 위한 상태를 저장하지 않는 팩토리를 제공합니다.
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
}
}
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를 활용하라고 권장하고 있습니다!
포스팅을 쓰면서 흔히 쓰던 Modifier에 대해서 좀 더 심도있게 공부하는 계기가 된것 같습니다! 성능이 좋다고 무조건 Modifier.Node만 쓰기보다는 각자의 상황에 맞게 사용하면 될것 같습니다!
오늘은 이상으로 포스팅 마치겠습니다!
오늘도 즐코하세요 🙇♂️
📚 Reference
Android Manifest Interview <저자: 엄재웅(skydoves)>