Compose Camp 2022 요약 - 1

6 분 소요

프로젝트 과목이 4개라 정신이 없던 11월 무렵, GDG에서 주관하는 Compose camp가 눈에 들어왔다. 컴포즈에 관심이 많았던 나는 그때 당장은 바쁘지만 일단 신청했다. 종강을 하고 곧바로 코드랩들을 하나씩 이수했다. 진행하며 새로 알게된 부분이나 중요하다고 생각하는 부분만 요약하여 정리하고자 글을 쓰게 되었다.

1편에서는 BasicLayout, Lazy layouts, Theming, State를 다룬다. 2편에서 나머지 내용을 정리할 것이다.

BasicLayout

텍스트 필드의 높이를 고정 높이가 아닌 Modifier.heightIn(min = x)을 사용하여 지정하자. 디자이너가 제공한 요소의 높이는 56dp라고 했을 때 Modifier.heightIn(min = 56.dp)과 같이 하면 된다. 이는 사용자 기기의 글꼴 크기가 다른 경우 더 큰 높이를 가질 수 있도록 해준다.

디자이너가 아래와 같이 텍스트 베이스 라인으로 패딩 값을 설정하는 경우 Modifier.paddingFromBaseline을 이용하면 된다.

paddingFromBaseline

행 혹은 열 요소간의 간격을 위해 Arrangement.spacedBy를 이용하면 된다.

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    modifier = modifier
) {
    items(alignYourBodyData) { item ->
        AlignYourBodyElement(item.drawable, item.text)
    }
}

Lazy layouts

Compose에서 스크롤 목록을 만드는 방법은 RecyclerView를 사용하는 것보다 굉장히 간단하다. RecyclerView를 구현하기 위해서는 xml로 항목 뷰를 만들고 Adapter, ViewHolder를 구현하며 최종적으로 바인딩까지 해줘야 한다. 하지만 컴포즈는 아래와 같이 굉장히 간단하게 할 수 있다.

LazyColum {
  items(someList) { item ->
    ListItem(item)
  }
}

컴포즈의 지연 레이아웃은 RecyclerView와는 동작 방식이 차이가 있다. RecyclerView는 이름처럼 뷰를 재활용하지만 컴포즈는 새로 구성하여 렌더링한다. 추가적인 팁으로는 스크롤 목록을 중첩하는 것을 권장하지 않으며, 항목의 크기가 0픽셀이면 안 되고, 항목에 고유 키를 제공하는 것을 권장한다. 단, 고유키는 정말로 고유한 키여야 한다. 고유키가 없는 경우 억지로 힘들게 만들 필요는 없다고 캠핑지기님이 말씀하셨다.

Theming

Color

Colors.contentColorFor에 배경 색상을 넘겨 사용하면 간단하게 배경 위의 요소 색상을 얻을 수 있다. 아래는 이 함수가 실제 구현된 코드이다.

fun Colors.contentColorFor(backgroundColor: Color): Color {
    return when (backgroundColor) {
        primary -> onPrimary
        primaryVariant -> onPrimary
        secondary -> onSecondary
        secondaryVariant -> onSecondary
        background -> onBackground
        surface -> onSurface
        error -> onError
        else -> Color.Unspecified
    }
}

Surface를 사용하여 콘텐츠의 기본 색상을 제공할 수 있다.

Surface(color = MaterialTheme.colors.primary) {
  Text(...) // default text color is 'onPrimary'
}
Surface(color = MaterialTheme.colors.error) {
  Icon(...) // default tint is 'onError'
}

LocalContentColor를 이용하여 현재 배경과 대비되는 색상을 가져올 수 있다.

BottomNavigationItem(
  unselectedContentColor = LocalContentColor.current ...
)

특정 텍스트는 강조하거나 덜 강조하기 위해서 텍스트의 투명도를 조절해야한다. 그럴때는 copy를 사용하는 것도 좋지만 compose에서 제공하는 LocalContentAlpha를 활용하는 것도 고려해보자. 상위요소를 CompositionLocalProvider로 감싸고 ContentAlpha를 제공하면 된다. 하위 요소들은 내부적으로 LocalContentAlpha를 이용하기 때문에 설정한 투명도가 적용된다.

// By default, both Icon & Text use the combination of LocalContentColor &
// LocalContentAlpha. De-emphasize content by setting a different content alpha
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
    Text(...)
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
    Icon(...)
    Text(...)
}

Text

앱에 기본적으로 이용하는 버튼을 만든다고 할 때 기본 텍스트 스타일을 지정해야 한다고 하자. 그럴땐 ProvideTextStyle를 이용하면 된다.

@Composable
fun Button(
    // many other parameters
    content: @Composable RowScope.() -> Unit
) {
  ...
  ProvideTextStyle(MaterialTheme.typography.button) { //set the "current" text style
    ...
    content()
  }
}

@Composable
fun Text(
    // many, many parameters
    style: TextStyle = LocalTextStyle.current // get the value set by ProvideTextStyle
) { ... }

하나의 텍스트에 여러 스타일을 적용해야하는 경우 마크업을 적용하는 AnnotatedString을 사용하면 된다. 동적으로 추가하거나 DSL 문법을 사용하여 콘텐츠를 만들 수 있다.

val text = buildAnnotatedString {
  append("This is some unstyled text\n")
  withStyle(SpanStyle(color = Color.Red)) {
    append("Red text\n")
  }
  withStyle(SpanStyle(fontSize = 24.sp)) {
    append("Large text")
  }
}

Animation

내부 크기가 변하는 요소의 경우 Modifier.animateContentSize를 이용하여 크기가 변하는 애니메이션을 쉽게 구현할 수 있다.

특정 값으로부터 여러 값이 변하는 애니메이션을 구현할 때는 transition을 이용하면 된다. 예를 들어 색상, 위치가 동시에 바뀌어야 하는 경우이다.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

InfiniteTransition를 이용하여 계속해서 반복하는 애니메이션을 만들 수 있다. rememberInfiniteTransition 함수를 사용하면 된다.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

Animatable은 낮은 수준의 애니메이션 API이다. stop를 이용하여 애니메이션을 중단할 수 있다. snapTo를 이용하여 해당 값으로 overwrite할 수 있다. animateTo로 타겟 값까지 애니메이션을 작동할 수 있다. 종합 예제는 아래와 같다. 스와이프하여 아이템을 제거하는 애니메이션을 구현한 코드이다. 포인터 이벤트를 기다리고 수평방향 드래그 하는 동안 offsetX를 갱신하다가 포인터가 떼지면 원래 위치로 복귀하거나 dismiss를 한다. 최종적으로 offsetX의 value를 Modifier.offset에 이용하여 애니메이션이 구동된다. 눈여겨 볼 곳은 decay에 현재 좌표와 velocity를 전달하여 dismiss인지 아닌지 계산을 하는 부분이다. 또한 awaitPointerXXX를 이용하여 포인터 이벤트를 대기하는 것이다.

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This Animatable stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the Animatable value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

State

먼저 상태의 정의부터 알아보자

상태: 시간이 지남에 따라 변할 수 있는 값

상태에 따라 특정 시점에 UI에 표시되는 항목이 결정된다. 그렇다면 무엇이 상태를 변경시킬까? 바로 이벤트이다. 상태는 존재하고 이벤트는 발생한다.

이번에는 컴포지션 단계에 알아보자. 컴포지션은 세 단계로 이루어진다.

  1. Composition: 어느 UI 요소를 가지고 있는지 파악한다.
  2. Layout: 각 요소를 어떻게 어디에 놓을지 측정 및 배치한다.
  3. Drawing: 각 요소 렌더링을 담당한다.

compose phases

Composable 함수는 내부적으로 State 값의 변경을 구독하고 있어 값이 변경되면 리컴포지션이 발생한다. 이때 remember를 이용하여 이 상태 값을 메모리에 저장해야 한다.

만약 분기문 안에 remember가 존재하는 경우는 어떻게 될까? 리컴포지션 중에 해당 remember가 호출 되지 않는 경우 해당 값은 메모리에서 삭제된다.

화면 회전, 언어 변경, 다크 모드 전환 등 엑티비티가 파괴되거나 프로세스가 재생성 될때 remember 값을 유지하기 위해서는 remeberSaveable을 이용하면된다. Bundle 값으로 사용할 수 있는 값만 사용할 수 있다. 그렇지 않은 값들은 직접 Saver를 구현하면 된다. list, map 방식 등이 존재한다.

왜 Stateless로 작성을 강조할까?

코드랩을 보면 state hosting을 이용하여 가능한 stateless로 컴포저블 함수를 작성하기를 권장한다. 그 이유는 아래와 같다.

  • 단일 진실 공급원(Single Truth Source)이다. 여러 곳에서 상태를 공급하면 추적이 어렵고 이는 예기치 못한 버그로 이어질 수 있다.
  • 재사용성을 높여준다. 상태가 없기 때문에 어느곳에서도 재사용될 수 있다.
  • 테스트하기 쉽다. 상태가 존재하는 컴포저블에 비해 테스트하기 훨씬 간단하다.

그런데 앱이 거대해지면 컴포저블 함수의 깊이가 깊어지게 될 것이다. 계속해서 매개변수로 값을 전달하게 될텐데 이는 어떻게 개선할 수 있을까? 하나의 방법으로 CompositionLocalProvider가 있다. 이는 암시적으로 하위 컴포넌트에게 값을 전달할 수 있게 해준다.

composition-local-provider

ViewModel

viewModel은 activity나 fragment에서 호출되는 루트 컴포저블 함수에서만 사용하는게 좋다. 즉, viewModel을 다른 컴포저블에 전달하지 말라는 이야기이다. 대신 필요한 데이터와 필수 로직을 실행하는 함수만 매개변수로 전달하는 것이 좋다. 이유는 재사용성과 테스트, 미리보기 편리성이 있기 때문이다. 따라서 UDF(Unidirectional Data Flow) 권장사항을 따라 필요한 값만 전달하는 것이 좋다.

유용한 단축키

WC: Column을 빠르게 생성한다.

댓글남기기