본문 바로가기

Compose

[ComposeInternals] 컴포즈 UI (Compose UI) - (3) Layout Constraints (제약 조건)

Jetpack Compose Internals 참고하여 작성하였습니다

 

 

레이아웃 제약 조건 (Layout Constraints)

제약 조건은 부모 LayoutNode나 modifier로부터 올 수 있다

자식 레이아웃을 측정하기 위해 제약 조건을 사용한다

 

대부분의 레이아웃은

1. 자식에게 수정되지 않은 제약 조건을 전달하거나

2. 최소 제약 조건을 완화한다 (0으로 설정) -> Box

 

부모 노드나 modifier는 자식에게 크기가 얼마인지 묻기도 한다

이 경우 무한한 제약 조건을 내려보낼 수 있다 (Constraints.Infinity)

자식 컴포넌트가 자신의 크기를 결정할 수 있는 자유가 부여된다 

 

예시) Box Composable

// 일반적으로 Box는 부모의 전체 높이를 채움
// LazyColumn 안에서는 Box는 무한 높이 제약으로 인해 Text를 감싸는 최소 크기로 조정됨
Box(modifier = Modifier.fillMaxHeight()) {
    Text("test")
}

 

 

Infinity Constraints를 활용하는 LazyColumn과 LazyList

LazyList는 자식을 측정할 때 Subcomposition(SubcomposeLayout)을 사용한다

 

LazyList에서 아이템을 측정하기 위한 제약 조건이 생성되는 방법

 

LazyListMeasuredItemProvider

@OptIn(ExperimentalFoundationApi::class)
internal abstract class LazyListMeasuredItemProvider @ExperimentalFoundationApi constructor(
    constraints: Constraints,
    isVertical: Boolean,
    private val itemProvider: LazyListItemProvider,
    private val measureScope: LazyLayoutMeasureScope
) {
    // 세로 스크롤(LazyColumn)에서는 높이가 무한대로 설정됨
    // 가로 스크롤(LazyRow)에서는 너비가 무한대로 설정됨
    val childConstraints = Constraints(
        maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity,
        maxHeight = if (!isVertical) constraints.maxHeight else Constraints.Infinity
    )
    ...
}

 

자식을 측정할 때마다 위 제약 조건이 사용된다

  • 먼저 Subcomposition(SubcomposeLayout)을 사용해 화면에 보이는 아이템만 실제로 구성한다
  • 아이템을 측정할 때
    • 아이템의 내용을 subcompose하여 measurable 목록을 생성
    • 생성된 childConstraints를 사용하여 측정
    • 높이나 너비에 대해 Infinity 제약 조건으로 인해 자식들은 자신의 높이나 너비를 선택할 수 있다
  • 정확한 크기(고정 크기)를 설정하는 경우
    • minWidth == maxWidth, minHeight == maxHeight로 설정
    • 자식 컴포넌트가 정확한 크기를 가지도록 강제함

 

LookaheadLayout

Compose에서 측정과 배치에 전적으로 관련됨

Lookahead는 미리보기, 선제적 계산, 미리 예측하는 행위를 의미한다

 

예시) 클릭 시 가변 상태(vertical)를 기반으로 레이아웃 전환

@Composable
fun SmartBox(modifier: Modifier = Modifier) {
    var vertical by remember { mutableStateOf(false) }
    
    Box(modifier = modifier.clickable { vertical = !vertical }) {
        if (vertical) {
            Column {
                Text("Text 1")
                Text("Text 2")
            }
        } else {
            Row {
                Text("Text 1")
                Text("Text 2")
            }
        }
    }
}

 

 

Text들은 완전히 동일하기에 공유 요소(shared element)가 되고, 쉽게 애니메이션 될 수 있으면 더 좋을 것이다

또한 애니메이션 중이나 후에 상태를 잃지 않고 재사용하도록 movableContentOf를 사용하는 것도 좋을 것이다

@Composable
fun SmartBox(modifier: Modifier = Modifier) {
    var vertical by remember { mutableStateOf(false) }

    val text1 = remember {
        movableContentOf {
            Text("Text 1")
        }
    }

    val text2 = remember {
        movableContentOf {
            Text("Text 2")
        }
    }

    LookaheadScope {
        Box(modifier = modifier.clickable { vertical = !vertical }) {
            AnimatedContent(
                targetState = vertical,
                transitionSpec = {
                    fadeIn() togetherWith fadeOut()
                }
            ) { isVertical ->
                if (isVertical) {
                    Column {
                        text1()
                        text2()
                    }
                } else {
                    Row {
                        text1()
                        text2()
                    }
                }
            }
        }
    }
}

 

위 예제에서는 LookaheadScope를 사용하지 않아도 차이가 나지 않는다

 

LookaheadScope은 변경될 때 직접적이거나 간접적인 자식들의 새로운 크기와 위치를 미리 계산할 수 있다

각 자식에게 측정/배치 단계에서 미리 계산된 값을 관찰할 수 있도록 하고 이 값을 사용해 시간이 지남에 따라 점진적으로 변화하도록 자신의 크기와 위치를 재조정할 수 있도록 한다

 

 

레이아웃을 사전 계산하는 방법으로서의 LookaheadLayout (Yet another way of pre-calculating layouts)

SubcomposeLayout, 고유 크기 측정(intrinsics), LookaheadLayout의 차이점

 

  • SubcomposeLayout
    • 사전배치(pre-layout)보다 조건부 composition에 초점
    • 측정 시간까지 composition을 지연시킨다
    • 측정, 배치 단계보다 훨씬 더 많은 비용이 들어 레이아웃의 사전 계산에 사용하는 것을 추천하지 않음
    • LazyList ..
  • 고유 크기 측정 (Intrinsics)
    • Subcomposition보다 효율적이고 내부적으로 LookaheadLayout과 유사
    • 얻어진 값을 사용해 실제 측정을 수행하기 위한 잠정적인 계산
    • 3개의 자식이 있는 행의 경우, 가장 키가 큰 자식의 높이에 맞추기 위해 모든 자식의 고유한 크기를 측정하기 때문
  • LookaheadLayout
    • 자동 애니메이션을 위해 자식의 크기와 위치를 정확하게 사전 계산하는 데 사용
    • 사전 계산된 크기에 기반한 배치(placement) 계산도 수행
    • 트리가 변경되지 않는 한 사전 계산을 피한다

 

LookaheadLayout의 동작 원리 (How it works)

측정과 배치에 대한 사전 단계를 수행해 일반 측정/배치 단계 동안 미리 계산된 값을 사용해 매 프레임마다 노드를 업데이트할 수 있게 한다

 

사전 측정 단계는

  • 트리 변경이나 State 변경으로 레이아웃이 변경될 때만 수행
  • 모든 레이아웃 애니메이션이 무시된다
  • 애니메이션이 이미 완료된 것처럼 수행 (미리 계산)

미리 계산된 데이터를 사용할 수 있도록 LookaheadLayoutScope 사용

  • LookaheadLayout은 content 람다를 LookaheadLayoutScope 스코프에서 실행
  • LookaheadLayoutScope는 자식에게 사전 계산된 정보에 접근할 수 있는 modifier 제공
  • Modifier.intermediateLayout(deprecated)
    • 현재는 Modifier.approachLayout으로 대체됨
    • modifier가 있는 레이아웃이 다시 측정될 때마다 호출
    • 레이아웃의 새로운 미리 계산된 크기에 접근할 수 있고 그 크기를 기반으로 예상되는 중간 레이아웃 생성
  • Modifier.onPlaced(deprecated)
    • onPlaced는 삭제됨
    • modifier가 있는 레이아웃의 재배치시에 호출

 

예시) Sample 코드

fun Modifier.animateConstraints(
    sizeAnimation: DeferredTargetAnimation<IntSize, AnimationVector2D>,
    coroutineScope: CoroutineScope
) =
    this.approachLayout(
        isMeasurementApproachInProgress = { lookaheadSize ->
            // Update the target of the size animation.
            sizeAnimation.updateTarget(lookaheadSize, coroutineScope)
            // Return true if the size animation has pending target change or hasn't finished running.
            !sizeAnimation.isIdle
        }
    ) { measurable, _ ->
        // First, update the target of the animation, and read the current animated size.
        val (width, height) = sizeAnimation.updateTarget(lookaheadSize, coroutineScope)
        // Then create fixed size constraints using the animated size
        val animatedConstraints = Constraints.fixed(width, height)
        // Measure child with animated constraints.
        val placeable = measurable.measure(animatedConstraints)
        layout(placeable.width, placeable.height) { placeable.place(0, 0) }
    }

 

 

Lookaheadlayout의 내부 동작 (Internals of Lookaheadlayout)

 

초기 측정과 배치 단계 동안 선제적 측정과 선제적 배치(주황색)를 포함해 어떤 일이 발생하는지 보여주는 다이어그램

 

측정 단계

Lookahead 측정 단계

  • LayoutNode가 처음 측정될 때, 선제적 측정 단계를 먼저 시작하기 위해 루트에 배치되었는지 확인
  • LayoutNode는 선제적 측정을 시작하기 위해 lookaheadPassDelegate measure 함수를 호출
    • 노드에 들어오는 모든 선제적 측정/배치 요청을 처리
    • 외부 LayoutNodeWrapper의 LookaheadDelegate를 사용해 선제적 측정을 실행
  • 모든 단계에서 LookaheadDelegate를 통해 선제적 측정이 실행된다
  • 선제적 측정이 완료되거나 노드가 LookaheadLayout의 루트가 아닌 경우 일반 측정 단계가 실행된다
  • 실제 측정 단계이기에 LookaheadPassDelegate가 아닌 MeasurePassDelegate가 사용된다

 

배치 단계

 

Lookahead 배치 단계

 

  • 측정 단계와 동일하며 같은 delegate가 사용된다
  • placeAt 함수를 호출하는 목적이 다르다
    • 일반 배치 단계에서는 노드와 자식들을 배치하기 위해 호출
    • 선제적 배치 단계에서는 선제적 위치를 계산하기 위해 호출