효율적인 Compose 애니메이션: Custom Switch에서 배우는 Recomposition 최적화

Seop
Spoon Radio
Published in
25 min readJun 29, 2024

우리는 Material Design에서 제공하는 Switch를 이용하여 UI를 개발할 수 있습니다. 그러나 브랜딩을 따르기 위해 Custom Switch의 개발이 필요한 경우도 발생합니다. 제가 소속된 팀 또한 Jetpack Compose를 활용하여 Custom Switch를 개발했습니다. 하지만 Layout Inspector를 활용하여 Custom Switch의 Recomposition 횟수를 확인해 보니, 예상치 못한 Recomposition이 다수 발생함을 확인할 수 있습니다.

아래 코드에서 Custom Switch의 check 상태가 변경될 때마다, 50회 이상의 Recomposition이 발생하는 것을 확인할 수 있습니다.

@Composable
fun Screen(
modifier: Modifier = Modifier
) {
var check by remember { mutableStateOf(false) }
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CustomSwitch(
check = check,
onClick = { isActive = !isActive}
)
}
}

@Composable
private fun CustomSwitch(
check: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val state by animateDpAsState(
targetValue = if (check) 150.dp else 0.dp,
animationSpec = tween(durationMillis = 1000),
label = "custom_switch"
)
Box(
modifier = modifier
.size(width = 200.dp, height = 50.dp)
.background(Color.LightGray)
.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.offset(state)
.size(50.dp)
.background(Color.Blue)
)
}
}
Custom Switch의 check 상태가 변경될 때마다, 50회 이상의 Recomposition 발생

반면, Material Design에서 제공하는 Switch의 경우, check 상태가 변경될 때마다 1회 Recomposition이 발생하는 것을 확인할 수 있습니다.

@Composable
fun Screen(
modifier: Modifier = Modifier
) {
var check by remember { mutableStateOf(false) }
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Switch(
checked = check,
onCheckedChange = {
isActive = it
}
)
}
}
Material3 Switch의 check 상태가 변경될 때마다, 1회 Recomposition 발생

동일한 Switch이지만 어떠한 차이로 인해 Recomposition 횟수의 차이가 발생하는 걸까요? 오늘은 Custom Switch를 개발하는 데 있어 애니메이션을 최적화하는 방법에 대해 알아보겠습니다.

01. Material3 Switch와 Custom Switch의 차이점 분석

💡 Material3 Switch와 Custom Switch는 다양한 차이점이 있습니다. 이번 포스팅에서는 Switch thumb의 ‘위치 변경’을 중심으로 이야기하려 합니다.

먼저 Material3 Switch의 구현을 확인해 보겠습니다. checked가 변경될 때마다, targetValue가 변경되고, 이는 offset(Animatable)까지 변경되는 것을 확인할 수 있습니다. 해당 offset(Animatable)은 State 상태로 변경되어, SwitchImpl의 thumbValue 인자의 값으로 할당됩니다.

@Composable
@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition")
fun Switch(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
thumbContent: (@Composable () -> Unit)? = null,
enabled: Boolean = true,
colors: SwitchColors = SwitchDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
...
val thumbPaddingStart = (SwitchHeight - uncheckedThumbDiameter) / 2
val minBound = with(LocalDensity.current) { thumbPaddingStart.toPx() }
val maxBound = with(LocalDensity.current) { ThumbPathLength.toPx() }
val valueToOffset = remember<(Boolean) -> Float>(minBound, maxBound) {
{ value -> if (value) maxBound else minBound }
}
val targetValue = valueToOffset(checked)
val offset = remember { Animatable(targetValue) }
val scope = rememberCoroutineScope(
...
Box(
...
) {
SwitchImpl(
...
thumbValue = offset.asState(),
...
)
}
}

SwitchImpl의 thumbValue로 전달된 상태 값은 thumbsOffset에 할당되며, Modifier.offset(offset: Density.() → IntOffset)를 통해 IntOffset으로 변환되는 것을 확인할 수 있습니다.

@Composable
@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition")
private fun BoxScope.SwitchImpl(
checked: Boolean,
enabled: Boolean,
colors: SwitchColors,
thumbValue: State<Float>,
thumbContent: (@Composable () -> Unit)?,
interactionSource: InteractionSource,
thumbShape: Shape,
uncheckedThumbDiameter: Dp,
minBound: Dp,
maxBound: Dp,
) {
...

val thumbOffset = if (isPressed) {
...
} else {
thumbValue.value
}
...
Box(modifier) {
...
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.offset { IntOffset(thumbOffset.roundToInt(), 0) }
...
) {
...
}
}
}

이어서 Modifier.offset(offset: Density.() → IntOffset)의 내부 구현을 확인해 보겠습니다. Modifier.offset(offset: Density.() → IntOffset)의 주석을 살펴보면, ‘offset이 변경될 때마다 Recomposition의 발생을 회피하고, 불필요한 redrawing을 막기 위해 GraphicLayer를 추가한다.’는 내용을 확인할 수 있습니다. 약간의 실마리가 확인된 것 같습니다. 그렇다면 Custom Switch Composable 구현에서 사용한 Modifier.offset(x: Dp, y: Dp)의 내부 구현도 확인해 보겠습니다.

/**
* Offset the content by [offset] px. The offsets can be positive as well as non-positive.
* Applying an offset only changes the position of the content, without interfering with
* its size measurement.
*
* This modifier is designed to be used for offsets that change, possibly due to user interactions.
* It avoids recomposition when the offset is changing, and also adds a graphics layer that
* prevents unnecessary redrawing of the context when the offset is changing.
*
* This modifier will automatically adjust the horizontal offset according to the layout direction:
* when the LD is LTR, positive horizontal offsets will move the content to the right and
* when the LD is RTL, positive horizontal offsets will move the content to the left.
* For a modifier that offsets without considering layout direction, see [absoluteOffset].
*
* @see [absoluteOffset]
*
* Example usage:
* @sample androidx.compose.foundation.layout.samples.OffsetPxModifier
*/
fun Modifier.offset(offset: Density.() -> IntOffset) = this then
OffsetPxElement(
offset = offset,
rtlAware = true,
inspectorInfo = {
name = "offset"
properties["offset"] = offset
}
)

Modifier.offset(x: Dp, y: Dp)의 주석에서는 Recompose 회피와 GrahpicLayer 추가에 대한 내용을 확인할 수 없습니다. 어떠한 차이가 더 있는지, 각 offset 함수를 구현하는 OffsetElement와 OffsetPxElement의 내부 구현을 살펴보겠습니다.

/**
* Offset the content by ([x] dp, [y] dp). The offsets can be positive as well as non-positive.
* Applying an offset only changes the position of the content, without interfering with
* its size measurement.
*
* This modifier will automatically adjust the horizontal offset according to the layout direction:
* when the layout direction is LTR, positive [x] offsets will move the content to the right and
* when the layout direction is RTL, positive [x] offsets will move the content to the left.
* For a modifier that offsets without considering layout direction, see [absoluteOffset].
*
* @see absoluteOffset
*
* Example usage:
* @sample androidx.compose.foundation.layout.samples.OffsetModifier
*/
@Stable
fun Modifier.offset(x: Dp = 0.dp, y: Dp = 0.dp) = this then OffsetElement(
x = x,
y = y,
rtlAware = true,
inspectorInfo = {
name = "offset"
properties["x"] = x
properties["y"] = y
}
)

Modifier.offset(x: Dp, y: Dp)는 OffsetElement를 이용하고 있습니다. OffsetElement의 create 콜백에서 OffsetNode를 생성하고, measure 수행 시 Placeable.place(x: Int, y: Int, zIndex: Float = 0f) 메서드를 이용하여 placeable의 위치를 결정하는 것을 확인할 수 있습니다.

private class OffsetElement(
val x: Dp,
val y: Dp,
val rtlAware: Boolean,
val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<OffsetNode>() {
override fun create(): OffsetNode {
return OffsetNode(x, y, rtlAware)
}
...
}

private class OffsetNode(
var x: Dp,
var y: Dp,
var rtlAware: Boolean
) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
if (rtlAware) {
placeable.placeRelative(x.roundToPx(), y.roundToPx())
} else {
placeable.place(x.roundToPx(), y.roundToPx())
}
}
}
}

반면, Modifier.offset(offset: Density.() → IntOffset)는 OffsetPxElement를 이용하고 있습니다. OffesetPxElement의 create 콜백에서 OffsetPxNode를 생성하고, measure 수행 시 Placeable.placeWithLayer() 메서드를 이용하여 placeable의 위치를 결정하는 것을 확인할 수 있습니다. 마지막으로 Placeable.place(x: Int, y: Int, zIndex: Float = 0f) 과 Placeable.placeWithLayer()의 차이를 살펴보겠습니다.

private class OffsetPxElement(
val offset: Density.() -> IntOffset,
val rtlAware: Boolean,
val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<OffsetPxNode>() {
override fun create(): OffsetPxNode {
return OffsetPxNode(offset, rtlAware)
}
...
}

private class OffsetPxNode(
var offset: Density.() -> IntOffset,
var rtlAware: Boolean
) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
val offsetValue = offset()
if (rtlAware) {
placeable.placeRelativeWithLayer(offsetValue.x, offsetValue.y)
} else {
placeable.placeWithLayer(offsetValue.x, offsetValue.y)
}
}
}
}

두 함수는 placeApparentToRealOffset 함수 호출 시, layerBlock의 전달 유무에 차이가 있는 것을 확인할 수 있습니다. 주석을 살펴보면 layerBlock은 x 또는 y 좌표로 이동 시 별도의 redrawing 과정 없이 GraphicLayer만 이동한다는 내용이 있습니다. 우리는 GraphicLayer의 차이로 Recomposition의 차이가 발생했는지 확인해 볼 필요가 있습니다.

/**
* Place a [Placeable] at [position] in its parent's coordinate system.
* Unlike [placeRelative], the given [position] will not implicitly react in RTL layout direction
* contexts.
*
* @param position position it parent's coordinate system.
* @param zIndex controls the drawing order for the [Placeable]. A [Placeable] with larger
* [zIndex] will be drawn on top of all the children with smaller [zIndex]. When children
* have the same [zIndex] the order in which the items were placed is used.
*/
fun Placeable.place(position: IntOffset, zIndex: Float = 0f) =
placeApparentToRealOffset(position, zIndex, null)


/**
* Place a [Placeable] at [x], [y] in its parent's coordinate system with an introduced
* graphic layer.
* Unlike [placeRelative], the given position will not implicitly react in RTL layout direction
* contexts.
*
* @param x x coordinate in the parent's coordinate system.
* @param y y coordinate in the parent's coordinate system.
* @param zIndex controls the drawing order for the [Placeable]. A [Placeable] with larger
* [zIndex] will be drawn on top of all the children with smaller [zIndex]. When children
* have the same [zIndex] the order in which the items were placed is used.
* @param layerBlock You can configure any layer property available on [GraphicsLayerScope] via
* this block. If the [Placeable] will be placed with a new [x] or [y] next time only the
* graphic layer will be moved without requiring to redrawn the [Placeable] content.
*/
fun Placeable.placeWithLayer(
x: Int,
y: Int,
zIndex: Float = 0f,
layerBlock: GraphicsLayerScope.() -> Unit = DefaultLayerBlock
) = placeApparentToRealOffset(IntOffset(x, y), zIndex, layerBlock)

02. Recomposition과 GraphicLayer 간 상관 관계

먼저, Comopse가 UI를 그리기 위한 단계는 아래와 같습니다.

  1. Composition : 상태가 변경됨에 따라 관련된 Composable 함수가 다시 실행되고 UI 트리가 업데이트됩니다.
  2. Layout : Composable의 크기와 위치를 결정합니다.
  3. Drawing : Composable이 화면에 그려집니다.

GraphicLayer는 Composable에 그래픽 변환(transition, alpha 등)을 적용하는 데 사용합니다. 해당 변환은 offscreen buffer를 통해 처리됩니다. 이는 Recomposition을 트리거 하지 않기 때문에, 효율성을 높이며 성능 최적화와 부드러운 UI 경험을 제공하는 데 유용하게 사용됩니다.

  1. Composition : 상태가 변경되지 않아 Composable 함수가 다시 실행되지 않습니다.
  2. Layout : GraphicLayer를 통해 변환이 발생하므로 Composable의 크기와 위치를 결정합니다.
  3. Drawing : offscreen buffer를 통해 화면에 그려집니다.

조금 더 직관적인 차이를 확인하기 위해, Modifier.graphicsLayer(block: GraphicsLayerScope.() -> Unit)와 Modifier.offset(x: Dp, y: Dp)에 따른 Recomposition 차이를 확인해 보겠습니다. Modifier.offset(x: Dp, y: Dp)가 적용된 Box는 500ms마다 계속해서 Recomposition이 발생하지만, Modifier.graphicsLayer(block: GraphicsLayerScope.() -> Unit)가 적용한 Box는 Recomposition이 skip 되는 것을 확인할 수 있습니다. 하지만 두 Composable에 적용된 애니메이션은 동일하게 동작합니다.

@Composable
fun Screen(
modifier: Modifier = Modifier
) {
var start by remember { mutableStateOf(false) }
var offsetX by remember { mutableStateOf(0.dp) }
LaunchedEffect(key1 = start) {
while (start) {
delay(500)
offsetX += 1.dp
}
}
Column(
modifier = modifier
.fillMaxSize()
.clickable { start = !start },
verticalArrangement = Arrangement.spacedBy(
space = 10.dp,
alignment = Alignment.CenterVertically
)
) {
Box(
modifier = Modifier
.offset(x = offsetX)
.size(30.dp)
.background(Color.Black)
)
Box(
modifier = Modifier
.graphicsLayer {
translationX = offsetX.toPx()
}
.size(30.dp)
.background(Color.Blue)
)
}
Modifier.graphicsLayer(block: GraphicsLayerScope.() -> Unit)와
Modifier.offset(x: Dp, y: Dp)의 Recomposition 차이

03. Custom Switch 개선하기

결과적으로 GraphicLayer를 활용하는 Modifier.offset(offset: Density.() → IntOffset)를 이용하여 Custom Switch의 thumb offset을 조정하면, Recomposition이 발생하지 않고 최적화된 애니메이션을 구현할 수 있습니다. 이번 포스팅을 통해 Custom Switch의 애니메이션을 최적화하는 방법을 배우고, 더 나은 사용자 경험을 제공할 수 있기를 바랍니다.

@Composable
fun Screen(
modifier: Modifier = Modifier
) {
var check by remember { mutableStateOf(false) }
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CustomSwitch(
check = check,
onClick = { check = !check }
)
}
}
@Composable
private fun CustomSwitch(
check: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val density = LocalDensity.current
val minBound = with(density) { 0.dp.toPx() }
val maxBound = with(density) { 150.dp.toPx() }
val state by animateFloatAsState(
targetValue = if (check) maxBound else minBound,
animationSpec = tween(durationMillis = 1000),
label = "custom_switch"
)
Box(
modifier = modifier
.size(width = 200.dp, height = 50.dp)
.background(Color.LightGray)
.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.offset { IntOffset(state.roundToInt(), 0) }
.size(50.dp)
.background(Color.Blue)
)
}
}
Custom Switch의 check 상태가 변경될 때마다, 1회 Recomposition 발생

Recap

  1. Custom Component 개발 시, Material Design에서 제공하는 기본 Component를 참고하여 개발합니다.
  2. Custom Component 개발 완료 후, Layout Inspector를 활용하여 최적화합니다.
  3. GraphicLayer는 offscreen buffer를 활용하여, Recomposition을 skip 할 수 있습니다. 이는 애니메이션 최적화하여 더 나은 사용자 경험을 제공하는데 유용합니다.

--

--

Seop
Spoon Radio
0 Followers
Writer for

안녕하세요. 안드로이드 개발자 썹 입니다 🧑‍💻