[Compose] Compose UI

easyhooon·2025년 10월 29일
post-thumbnail

Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.

내용 보충 필요!

Compose UI

클라이언트 라이브러리로 노드 트리의 구체화(materialization)의 책임을 가짐

Composition은 여러 개일 수 있다

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 과정과는 구분된다.

AndroidView에서는 ReusableNode가 아닌 표준 ComposeNode를 사용한다

LayoutNode에는 적용되지만 AndroidView에 대해서는 적용되지 않는다.

ReusableNode: key 변경시 새로 생성하지 않고 update만 함
AndroidView: key 변경시 버리고 새로 생성

Subcomposition

Composition 은 루트 레벨에서만 존재하는 것이 아니다.

Composition은 Composable 트리의 더 깊은 수준에서 생성될 수 있으며 부모 Composition과 연결될 수 있다.

이것을 Compose에서 Subcomposition이라 부른다.

각 Composition은 부모 Composition을 나타내는 부모의 CompositionContext에 대한 참조를 가지며 루트 Composition은 예외적으로 그 부모는 Recomposer 자체 이다.

초기 Composition 과정의 지연

SubcomposeLayout은 Layout과 유사하지만, 레이아웃 단계에서 독립적인 Composition을 생성하고 실행함
이를 통해 SubcomposeLayout은 자식 Composable들로 하여금 자신 안에서 계산된 값에 의존하도록 할 수 있음

Subcomposition 사용 목적

  1. 초기 composition 과정에서 특정 정보를 알 때까지 연기하기 위해
  2. 하위(서브) 트리에서 생성되는 노드의 타입을 변경하기 위해

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

LayoutNode는 자체적으로 자신을 구체화(자기 자신의 측정/배치를 스스로 수행) 할 수 있기 때문에 Compose Runtime이 플랫폼에 대해 알지 못하도록 할 수 있음

LayoutNode는 부모 노드에 대한 참조를 가지고 잇으며, 모든 노드는 동일한 Owner에 연결되어 있음

LayoutNode는 순수한 Kotlin 클래스이며, Android 의존성이 없다. 단지 UI 노드를 모델링하기 때문.

Modifier 는 상태가 없다.

Modifier의 측정된 크기를 어딘가에 보관할 필요가 있다면(상태를 유지하려면) 래퍼가 필요
이러한 이유로 LayoutNode는 내, 외부 래퍼뿐만 아니라 적용된 modifier 각각에 대한 래퍼를 가짐.

측정 정책(Measuring policies)

Layout (기본 측정 정책)

Layout(content) { measurables, constraints ->
  // 직접 측정 로직 작성
  val placeables = measurables.map { it.measure(constraints) }
  
  layout(width, height) {
    placeables.forEach { it.place(x, y) }
  }
}

완전 커스텀, 모든 제어 가능
제약 전달/크기 계산 직접 구현

Box

// 측정: 자식들 동일 제약으로 측정
children.forEach { it.measure(constraints) }

// 크기: max(모든 자식 크기)
width = max(child1.width, child2.width, ...)
height = max(child1.height, child2.height, ...)

// 배치: contentAlignment 기준 정렬

자식들 겹쳐 쌓기
크기는 가장 큰 자식 기준

Row

// 측정: 가로 공간 분배
remainingWidth = constraints.maxWidth
children.forEach { 
  it.measure(Constraints(maxWidth = remainingWidth))
  remainingWidth -= it.width
}

// 크기
width = sum(children.width) + spacing
height = max(children.height)

// 배치: horizontalArrangement + verticalAlignment

가로 나열, 순차 측정
높이는 최대값

Column

// 측정: 세로 공간 분배
remainingHeight = constraints.maxHeight
children.forEach {
  it.measure(Constraints(maxHeight = remainingHeight))
  remainingHeight -= it.height
}

// 크기
width = max(children.width)
height = sum(children.height) + spacing

// 배치: verticalArrangement + horizontalAlignment

세로 나열, 순차 측정
너비는 최대값

LookaheadLayout(-> LookaheadScope로 변경)

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 가능. 예측 불가능한 레이아웃 변화를 예측 가능하게 만듦.

Semantics

UI의 의미(meaning) 정보를 accessibility 서비스에 전달하는 메타데이터 레이어.

시각 트리: 화면에 그려지는 것
Semantics 트리: 접근성/테스트용 의미 정보

용도

Accessibility (TalkBack/VoiceOver)

화면 읽어주기
버튼/텍스트 역할 인식

Testing

onNodeWithText(), onNodeWithContentDescription()
UI 요소 찾기/검증

Search/Indexing

앱 콘텐츠 크롤링

사용법

// 기본 제공 (자동)
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 기준으로 요소 찾음.

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글