Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.
클라이언트 라이브러리로 노드 트리의 구체화(materialization)의 책임을 가짐
setContent 함수는 새로운 루트 Composition을 생성하고, 가능한 한 재사용한다.
각각의 독립적인 Composable 트리를 호스팅하기 때문에 루트라고 부름
Fragment에 여러개의 ComposeView를 선언하고, 각 View에서 setContent를 호출하면 setContent를 호출한 만큼 루트 Composition을 가지게 된다.
class MyFragment : Fragment() {
private val composeView1 = ComposeView(requireContext())
private val composeView2 = ComposeView(requireContext())
private val composeView3 = ComposeView(requireContext())
override fun onCreateView(...): View {
return LinearLayout(requireContext()).apply {
addView(composeView1) // ComposeView 1
addView(composeView2) // ComposeView 2
addView(composeView3) // ComposeView 3
}
}
override fun onViewCreated(...) {
composeView1.setContent { /* Composition 1 */ }
composeView2.setContent { /* Composition 2 */ }
composeView3.setContent { /* Composition 3 */ }
}
}
Composition 이라는 slot table이 사용된다는 의미이며, Compose의 생명주기인 composition 과정과는 구분된다.
LayoutNode에는 적용되지만 AndroidView에 대해서는 적용되지 않는다.
ReusableNode: key 변경시 새로 생성하지 않고 update만 함
AndroidView: key 변경시 버리고 새로 생성
Composition 은 루트 레벨에서만 존재하는 것이 아니다.
Composition은 Composable 트리의 더 깊은 수준에서 생성될 수 있으며 부모 Composition과 연결될 수 있다.
이것을 Compose에서 Subcomposition이라 부른다.
각 Composition은 부모 Composition을 나타내는 부모의 CompositionContext에 대한 참조를 가지며 루트 Composition은 예외적으로 그 부모는 Recomposer 자체 이다.
SubcomposeLayout은 Layout과 유사하지만, 레이아웃 단계에서 독립적인 Composition을 생성하고 실행함
이를 통해 SubcomposeLayout은 자식 Composable들로 하여금 자신 안에서 계산된 값에 의존하도록 할 수 있음
Subcomposition의 사용 예시: BoxWithConstraints
BoxWithConstraints {
val rectangleHeight = 100.dp
if (maxHeight < rectangleHeight * 2) {
Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
} else {
Column {
Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
Box(Modifier.size(50.dp, rectangleHeight).background(Color.Gray))
}
}
}
Subcomposition은 서브 트리에 완전히 다른 노드 타입을 지원할 수 있도록 해준다.
예시: rememberVectorPainter
VNode라는 다른 노드 타입으로 Subcomposition을 구성
VNode는 재귀적인 타입으로, 단일 경로나 경로 그룹을 모델링
@Composable
fun MenuButton(onMenuClick: () -> Unit) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Menu),
contentDescription = "Menu button",
modifier = Modifier.clickable { onMenuClick() }
)
}
LayoutNode는 자체적으로 자신을 구체화(자기 자신의 측정/배치를 스스로 수행) 할 수 있기 때문에 Compose Runtime이 플랫폼에 대해 알지 못하도록 할 수 있음
LayoutNode는 부모 노드에 대한 참조를 가지고 잇으며, 모든 노드는 동일한 Owner에 연결되어 있음
LayoutNode는 순수한 Kotlin 클래스이며, Android 의존성이 없다. 단지 UI 노드를 모델링하기 때문.
Modifier의 측정된 크기를 어딘가에 보관할 필요가 있다면(상태를 유지하려면) 래퍼가 필요
이러한 이유로 LayoutNode는 내, 외부 래퍼뿐만 아니라 적용된 modifier 각각에 대한 래퍼를 가짐.
Layout(content) { measurables, constraints ->
// 직접 측정 로직 작성
val placeables = measurables.map { it.measure(constraints) }
layout(width, height) {
placeables.forEach { it.place(x, y) }
}
}
완전 커스텀, 모든 제어 가능
제약 전달/크기 계산 직접 구현
// 측정: 자식들 동일 제약으로 측정
children.forEach { it.measure(constraints) }
// 크기: max(모든 자식 크기)
width = max(child1.width, child2.width, ...)
height = max(child1.height, child2.height, ...)
// 배치: contentAlignment 기준 정렬
자식들 겹쳐 쌓기
크기는 가장 큰 자식 기준
// 측정: 가로 공간 분배
remainingWidth = constraints.maxWidth
children.forEach {
it.measure(Constraints(maxWidth = remainingWidth))
remainingWidth -= it.width
}
// 크기
width = sum(children.width) + spacing
height = max(children.height)
// 배치: horizontalArrangement + verticalAlignment
가로 나열, 순차 측정
높이는 최대값
// 측정: 세로 공간 분배
remainingHeight = constraints.maxHeight
children.forEach {
it.measure(Constraints(maxHeight = remainingHeight))
remainingHeight -= it.height
}
// 크기
width = max(children.width)
height = sum(children.height) + spacing
// 배치: verticalArrangement + horizontalAlignment
세로 나열, 순차 측정
너비는 최대값
2-pass 레이아웃 시스템. 애니메이션 중 레이아웃 변화를 미리 계산.
Lookahead라는 용어는 미리 보기, 선제적 계산 또는 예측하는 행위를 의미
Lookahead pass: 최종 목표 레이아웃 측정
Main pass: 현재 프레임 실제 레이아웃 적용
애니메이션 중 레이아웃 점프 방지
부모-자식 간 크기 변화 동기화
부드러운 shared element transition
// LookaheadLayout 없으면: 아이템이 툭툭 끊김
// LookaheadLayout 있으면: 주변 아이템도 함께 부드럽게 이동
SharedTransitionScope 내부에서 LookaheadScope를 사용
@ExperimentalSharedTransitionApi
@Composable
public fun SharedTransitionScope(content: @Composable SharedTransitionScope.(Modifier) -> Unit) {
LookaheadScope {
val coroutineScope = rememberCoroutineScope()
val sharedScope = remember { SharedTransitionScopeImpl(this, coroutineScope) }
sharedScope.content(
Modifier.layout { measurable, constraints ->
val p = measurable.measure(constraints)
layout(p.width, p.height) {
val coords = coordinates
if (coords != null) {
if (!isLookingAhead) {
sharedScope.root = coords
} else {
sharedScope.nullableLookaheadRoot = coords
}
}
p.place(0, 0)
}
}
.drawWithContent {
drawContent()
sharedScope.drawInOverlay(this)
}
)
DisposableEffect(Unit) { onDispose { sharedScope.onDispose() } }
}
}
LookaheadScope {
// 내부에서 lookaheadSize, intermediateLayout 사용 가능
Box(Modifier.intermediateLayout { ... })
}
목표 레이아웃을 미리 알아야 중간 프레임들을 interpolation 가능. 예측 불가능한 레이아웃 변화를 예측 가능하게 만듦.
UI의 의미(meaning) 정보를 accessibility 서비스에 전달하는 메타데이터 레이어.
시각 트리: 화면에 그려지는 것
Semantics 트리: 접근성/테스트용 의미 정보
Accessibility (TalkBack/VoiceOver)
화면 읽어주기
버튼/텍스트 역할 인식
onNodeWithText(), onNodeWithContentDescription()
UI 요소 찾기/검증
앱 콘텐츠 크롤링
// 기본 제공 (자동)
Text("Hello") // semantics { text = "Hello" }
Button(onClick = {}) // semantics { role = Role.Button }
// 수동 추가
Box(
modifier = Modifier.semantics {
contentDescription = "프로필 이미지"
role = Role.Image
onClick { /* action */ }
}
)
// 병합 (자식 semantics 합치기)
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(...)
Text("설정")
}
// → TalkBack: "설정 버튼" (하나로 읽음)
// 숨기기
Box(modifier = Modifier.clearAndSetSemantics {}) // 자식 무시
contentDescription: 시각 설명
role: Button/Checkbox/Image 등
stateDescription: "선택됨/해제됨"
onClick/onLongClick: 동작
testTag: 테스트용 ID
화면에 안 보이지만 접근성 서비스가 읽는 별도 정보 트리
테스트 작성 시 semantics 기준으로 요소 찾음.