Compose로 화면을 만들기 시작하면 처음엔 깔끔한데, 어느 순간부터 이게 슬슬 이상해진다.
- Scaffold 안에
- 상단 툴바 들어가고
- 탭 들어가고
- 상태바 패딩도 넣어야 하고
- 플로팅 버튼에, 스낵바에, 로딩 레이어에…
"아 이거 한 파일에 계속 써도 되나…?" 싶어지면 보통
여러개의 컴포저블로 나눠서 그려보려고 한다. 그러다보면 재활용도 하고 싶고
쉽게 시도해보는게 Slot API 인데 이게 또 복잡하면 문제가 발생한다.
Compose 화면이 망가지는 과정
보통 화면이 이렇게 커진다.
- 처음엔 ScreenA() 같은 함수 하나에서 시작
- 거기에 TopBar, TabRow, 리스트, 다이얼로그, 로딩, 에러 뷰 등등이 한데 모이기 시작
- 어느 순간 @Composable fun ScreenA() 하나가 300줄이 넘어감
- 이제서야 아 좀 나눠볼까 하고 나누기 시작함
- 파라미터로 넘겨야 하는 값이 10개가 넘어가고
- 콜백도 5~6개씩 달리기 시작
- 결국 관리하기 힘든 화면 완성
이때 필요한 게 Compound Component다.
물론 멋쟁이 개발자들은 처음부터 잘 나눠서 만들거라고 믿는다.
Compound Component 패턴이 뭐냐면
말만 거창하지 개념은 간단하다.
여러 개의 작은 컴포넌트를 하나의 덩어리처럼 다루면서
내부 슬롯을 적당히 열어둬서 커스터마이징은 가능하게 만드는 패턴
조금 더 안드로이드스럽게 말하면
- CustomDialog 하나를 만들어두고
- 그 안에 제목 영역, 본문 영역, 버튼 영역을
- 필요한 만큼만 열어준 상태로 하나의 컴포넌트처럼 쓰는 것
Compose에서는 이걸 람다 + Scope (람다 리시버) 로 예쁘게 만들 수 있다.
한번 찍먹해보자
예를 들어 이런 컴포넌트가 있다고 해보자.
@Composable
fun MyBox(
title: String?,
content: String?,
mainButtonText: String?,
boxContent: @Composable MyBoxScope.() -> Unit,
) {
val scope = remember(title, content, mainButtonText) {
MyBoxScopeImpl(
title = title,
content = content,
mainButtonText = mainButtonText,
)
}
Column {
scope.boxContent()
}
}
처음 보면 약간 어색한 부분이 있다.
- scope라는 걸 만든 다음
- scope.boxContent()만 호출했는데
- 그 안에서 뭔가가 그려진다?
이게 바로 람다 리시버 + Scope 덕분이다.
Scope + 람다 리시버가 실제로 하는 일
먼저 MyBoxScope를 보자.
interface MyBoxScope {
val title: String?
val content: String?
val mainButtonText: String?
@Composable
fun Title()
@Composable
fun Content()
@Composable
fun MainButton(onClick: () -> Unit)
}
다음은 구현체
class MyBoxScopeImpl(
override val title: String?,
override val content: String?,
override val mainButtonText: String?,
) : MyBoxScope {
@Composable
override fun Title() {
if (title != null) {
Text(text = title)
}
}
@Composable
override fun Content() {
if (content != null) {
Text(text = content)
}
}
@Composable
override fun MainButton(onClick: () -> Unit) {
if (mainButtonText != null) {
Button(onClick = onClick) {
Text(mainButtonText)
}
}
}
}
이제 사용 코드를 보면 감이 온다.
@Composable
fun SampleScreen() {
MyBox(
title = "타이틀",
content = "내용입니다",
mainButtonText = "확인",
) {
// 여기 this == MyBoxScope
Title()
Spacer(Modifier.height(8.dp))
Content()
Spacer(Modifier.height(16.dp))
MainButton(onClick = { /* 클릭 처리 */ })
}
}
MyBox { } 블록 안에서
- Title(), Content(), MainButton()은 전부 MyBoxScope의 함수고
- 이 블록의 this는 MyBoxScope이기 때문에
- scope.boxContent()를 호출하면, 그 안의 Title() / Content() / MainButton()이
- 실제로는 MyBoxScopeImpl의 구현을 타고 렌더링된다.
결론적으로 이 구조 때문에 한 번 scope만 만들어놓고 그걸 기반으로 작은 DSL처럼 쓰는게 가능해진다.
굳이 람다 리시버를 써야 해? 라는 의문
사실 Slot API 만으로도 대부분의 화면은 충분히 커버할 수 있다.
그런데 화면이 점점 커지고 커스터마이징 포인트가 늘어나면 이렇게 된다
- 변경사항이 생길떄마다 파라미터를 계속 추가해야함
- '이 슬롯은 이 조합에서만 의미 있다' 같은 제약을 표현하기 어려움
- title, content, buttonText 등을 넘겨놓고도 실제 그리는 책임이 여기저기 흩어진다
이럴 때 Scope 기반 Compound Component가 빛을 발한다.
Scope 기반 Compound Component가 좋은 지점
Scope를 쓰면 다음이 가능해진다.
내부 API 를 노출할 수 있다
예를 들어 다이얼로그 컴포넌트를 만든다고 하면
interface DialogScope {
@Composable fun Title(text: String = defaultTitle)
@Composable fun Description()
@Composable fun PrimaryButton(onClick: () -> Unit)
@Composable fun SecondaryButton(onClick: () -> Unit)
fun dismiss()
}
dismiss() 같은 함수까지 Scope에 넣어두면
CustomDialog(visible = isVisible) {
Title()
Description()
PrimaryButton {
// 저장 로직
dismiss()
}
}
이렇게 이 컴포넌트 안에서만 의미 있는 동작을 자연스럽게 녹여 넣을 수 있다.
Scope를 쓰면 UI + 행동을 함께 캡슐화한 미니 DSL을 하나 만든 느낌이 된다.
복잡한 제약을 Scope 안으로 숨길 수 있다
예를 들어 메인 버튼은 항상 하단 고정 + 로딩일 때는 비활성 + 특정 상태에서만 활성 같은 로직이 있다고 하자.
class DialogScopeImpl(
private val uiState: DialogUiState,
private val onConfirm: () -> Unit,
) : DialogScope {
@Composable
override fun PrimaryButton(onClick: () -> Unit) {
Button(
onClick = { if (!uiState.isLoading) onClick() },
enabled = uiState.canConfirm && !uiState.isLoading,
) {
if (uiState.isLoading) {
CircularProgressIndicator()
} else {
Text("확인")
}
}
}
}
그러면 사용하는 쪽에서는 그냥
CustomDialog(uiState = uiState) {
Title()
Description()
PrimaryButton {
onConfirm() // isLoading, enabled 같은 건 신경 안 써도 됨
}
}
화면 설계하는 사람 입장에서는
- 로딩이면 버튼 눌리면 안된다
- 특정 플래그가 false면 버튼 비활성한다
같은 걸 매번 생각할 필요가 없다.
Scope 안으로 제약을 밀어 넣고, 외부에서는 DSL만 쓰게 만드는 것이 핵심이다.
복잡한 화면 하나 = Compound Component 하나로 다루기
예를 들어 구독 관리 화면이 있다고 해보자.
- 상단에 요약 정보 카드
- 탭으로 구독 중 / 만료 / 추천 플랜
- 하단에 구독 해지 버튼
- 중간중간 토스트, 스낵바, 로딩, 에러 레이어
이걸 하나의 SubscriptionScreenScaffold 같은 Compound Component로 묶을 수 있다.
@Composable
fun SubscriptionScreenScaffold(
uiState: SubscriptionUiState,
onEvent: (SubscriptionEvent) -> Unit,
content: @Composable SubscriptionScope.() -> Unit,
) {
val scope = remember(uiState) {
SubscriptionScopeImpl(uiState = uiState, onEvent = onEvent)
}
Scaffold(
topBar = { scope.TopBar() },
bottomBar = { scope.BottomBar() },
snackbarHost = { scope.SnackbarHost() },
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
scope.content()
scope.LoadingLayer()
scope.ErrorLayer()
}
}
}
사용은 이렇게
@Composable
fun SubscriptionScreen(viewModel: SubscriptionViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsState()
SubscriptionScreenScaffold(
uiState = uiState,
onEvent = viewModel::onEvent,
) {
// 여기 this == SubscriptionScope
SummaryCard()
TabContent()
}
}
이렇게 되면
- Scaffold 구조, 스낵바, 로딩, 에러, 이벤트 디스패치 같은 건 전부 SubscriptionScope가 들고 있고
- 실제 콘텐츠는 SummaryCard(), TabContent()만 호출해서 채워 넣으면 된다.
결국 화면 설계할 때 관점이 바뀐다.
Composable 여러 개를 배치하는 느낌에서 이 화면만의 DSL로 스크린을 선언하는 느낌으로
언제 Scope까지 쓰고, 언제 그냥 Slot으로 끝낼까
실제로 써보면서 느낀 기준은 대충 이렇다.
그냥 Slot API로 충분한 경우
- 단순 재사용 컴포넌트
- 슬롯이 2~3개 이내어가 제약 로직도 별로 없는 경우
- 이 컴포넌트만의 행동이라는게 거의 없는 경우
Scope + Compound Component가 좋은 경우
- 화면 단위, 혹은 화면의 큰 섹션 단위
- 내부에 들어가는 요소들이 많고 각각 서로 상태/제약을 공유해야 하는 경우
- 이 화면에서만 의미 있는 행동/로직을 UI와 함께 묶어서 노출하고 싶은 경우
- 도메인 단위로 캡슐화하고 싶은 경우
한 줄로 요약하면
그냥 예쁜 View 조각이면 Slot, 이 도메인의 규칙이 잔뜩 묻어 있는 덩어리라면 Scope 기반 Compound Component
Compose 자체는 선언형이라 처음엔 단순하다. 그러나
도메인 규칙이 쌓이기 시작하면 UI 코드가 진짜 금방 더러워진다.
이럴 때 적절하게 써주자
'Android > Android Compose' 카테고리의 다른 글
| 안드로이드 컴포즈 네비게이션 startDestination 에 argument 전달하기 (1) | 2024.12.18 |
|---|---|
| Compose + AGSL 셰이더를 이용해서 간지나는 카드 애니메이션 만들기 (0) | 2024.10.31 |
| 안드로이드 compose 비트맵으로 캡쳐하기 (3) | 2024.09.02 |
| Android Compose 의 @Immutable 와 @Stable (0) | 2024.08.20 |
| 컴포즈의 Side-Effect 형제들에 대해서 알아보자 - 2 (0) | 2024.04.28 |
| 컴포즈의 Side-Effect 형제들에 대해서 알아보자 - 1 (0) | 2024.04.21 |
| Jetpack Compose CompositionLocal 에 대해 알아봅시다 (0) | 2024.03.31 |
| 스와이프 삭제 구현 (0) | 2024.03.10 |