Composable 함수 : Compose의 기본 구성 요소. UI의 일부를 정의하는 Unit
을 리턴하는 함수
레이아웃 모델에서 UI 트리는 단일 패스로 배치된다. 각 노드는 자신을 측정한 다음 모든 하위 element를 재귀적으로 측정하여 트리 아래로 크기 제약 조건을 하위 element에게 전달한다. 다음으로 리프 노드의 크기와 배치가 결정되고, 결정된 크기와 배치 정의가 트리 위로 다시 전달된다.
@Composable
fun SearchResult() {
Row {
Image(
// ...
)
Column {
Text(
// ...
)
Text(
// ...
)
}
}
}
위 코드로 생성된 UI 트리는 다음과 같다.
SearchResult
Row
Image
Column
Text
Text
Compose는 하위 element를 한 번만 측정하여 높은 성능을 달성한다. 단일 패스 측정은 성능에 유리하며, Compose가 깊은 UI 트리를 효율적으로 처리할 수 있게 해준다. 레이아웃에 여러 번의 측정이 필요한 경우, Compose 레이아웃의 내장 기능 측정을 사용할 수 있다.
측정(measurement)과 배치(measurement)는 레이아법웃 단계의 개별 하위 단계이므로 측정이 아닌 항목 배치에만 영향을 미치는 변경 사항은 별도로 실행할 수 있다.
Modifier를 사용하여 Composable에 기능을 더 할 수 있다. Modifier는 레이아웃을 커스텀하는 데 필수적이다.
Column(
Modifier
.clickable(onClick = /*...*/)
.padding(/*...*/)
.fillMaxWidth()
) {
/*...*/
}
레이아웃은 다양한 화면 방향과 폼 팩터 크기를 고려하여 디자인해야 함
부모에서 오는 제약 조건 확인하고 레이아웃을 디자인하려면 BoxWithConstraints를 사용
측정 제약 조건을 사용하여 다양한 다른 레이아웃을 구성할 수 있다
@Composable
fun WithConstraintsComposable() {
BoxWithConstraints {
/*...*/
}
}
Material Component는 슬롯 API를 사용
TopAppBar에서 커스텀할 수 있는 슬롯의 위와 같다
Composable은 content Composable Lambda( content: @Composable () -> Uni)를 사용한다. 슬롯 API는 특정 용도를 위해 여러 콘텐츠 파라매터를 노출한다.
TopAppBar는 title, navigationIcon, actions을 제공하고 있다.
Scaffold를 사용하면 기본 머티리얼 디자인 레이아웃 구조로 UI를 구현 가능하다.
@Composable
fun HomeScreen(/*...*/) {
ModalNavigationDrawer(drawerContent = { /* ... */ }) {
Scaffold(
topBar = { /*...*/ }
) { contentPadding ->
// ...
}
}
}
Modifier로 가능한 것들
모든 Composable이 Modifier 파라미터로 전달 및 UI에 적용하는 것은 첫 번째 하위 element로 하는 것을 권장
API 가이드라인 : Elements accept and respect a Modifier parameter
각 Modifier 함수는 이전 함수에서 반환된 값을 변경
Compose에는 특정 Composable의 하위 항목에 적용될 때만 사용할 수 있는 Modifier가 있다.
기존 Android View 시스템에는 범위 안전이 없다
Box의 matchParentSize
Row/Column의 weight
때로는 동일한 Modifier 체인 인스턴스를 변수로 추출하여 여러 Composable에서 재사용하는 것이 유용할 수 있다.
val reusableModifier = Modifier
.fillMaxWidth()
.background(Color.Red)
.padding(12.dp)
애니메이션/스크롤 상태와 같이 Composable 내에서 자주 변경되는 상태를 관찰할 때 많은 양의 Recomposition이 수행되며, 모든 Recomposition 및 모든 프레임에 대해 Modifier가 할당된다.
@Composable
fun LoadingWheelAnimation() {
val animatedState = animateFloatAsState(/*...*/)
LoadingWheel(
// 이 Modifier의 생성/할당은 애니메이션의 모든 프레임에서 발생
modifier = Modifier
.padding(12.dp)
.background(Color.Gray),
animatedState = animatedState
)
}
val reusableItemModifier = Modifier
.padding(bottom = 12.dp)
.size(216.dp)
.clip(CircleShape)
@Composable
private fun AuthorList(authors: List<Author>) {
LazyColumn {
items(authors) {
AsyncImage(
// ...
modifier = reusableItemModifier,
)
}
}
}
추출된 Scope Modifier는 동일한 범위의 직계 하위 항목에만 전달해야 한다
Column(modifier = Modifier.fillMaxWidth()) {
// Weight Modifier는 Composable ColumnScope로 지정
val reusableItemModifier = Modifier.weight(1f)
// Column의 직계 하위 항목
Text1(
modifier = reusableItemModifier
// ...
)
Box {
Text2(
// Text Composable은 Column의 직접적인 자식이 아니다.
// Weight는 여기서 아무 작업도 수행하지 않는다.
modifier = reusableItemModifier
// ...
)
}
}
.then() 함수를 사용하여 추출된 Modifier 체인을 추가로 연결하거나 추가 가능
val reusableModifier = Modifier
.fillMaxWidth()
.background(Color.Red)
.padding(12.dp)
// reusableModifier에 추가
reusableModifier.clickable { /*...*/ }
// otherModifier에 reusableModifier를 추가
otherModifier.then(reusableModifier)
UI 트리의 레이아웃 노드를 래핑하는 수정자 | Modifier 체인으로 시각화한 UI 트리 |
---|---|
각 단계에서 레이아웃 노드의 너비, 높이 및 x, y 좌표를 찾는다
제약조건 유형
size Modifier가 50dp의 크기를 사용하려고 해도 부모로부터 전달되는 최소 제약 조건을 준수해야 함. 따라서 size Modifier의 값은 무시하고 300x200의 정확한 제약 조건 bound를 출력. 이미지는 300x200의 크기를 전달
Modifier의 구성
기존 Modifier를 사용하여 커스텀 Modifier 생성 가능
// Modifier#clip의 구현
fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)
// 반복 사용되는 경우
fun Modifier.myBackground(color: Color) = padding(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
Composable modifier factory : Composable 함수를 사용하여 Modifier를 만들어 전달 가능
@Composable
fun Modifier.fade(enable: Boolean): Modifier {
val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
return this then Modifier.graphicsLayer { this.alpha = alpha }
}
CompositionLocal를 Composable modifier factory에서도 사용할 수 있지만, 생성 시점의 Composition tree의 값이 사용된다
@Composable
fun Modifier.myBackground(): Modifier {
val color = LocalContentColor.current
return this then Modifier.background(color.copy(alpha = 0.5f))
}
@Composable
fun MyScreen() {
CompositionLocalProvider(LocalContentColor provides Color.Green) {
// Background modifier 생성 (Background는 Green)
val backgroundModifier = Modifier.myBackground()
// LocalContentColor를 Red로 업데이트
CompositionLocalProvider(LocalContentColor provides Color.Red) {
// Red가 아닌 Green이 background로 적용
Box(modifier = backgroundModifier)
}
}
}
Composable function modifier는 Skip하지 않음
반환값이 있는 Composable 함수는 Skip이 불가능하므로 Composable modifier factory도 Skip하지 않음.
Composable function modifier의 호출 위치
Modifier.Node는 Compose에서 Modifier를 만들기 위한 하위 수준의 API. 가장 성능이 좋은 방법
composed {}는 성능 문제로 권장하지 않는 API
ModifierNodeElement와 비교
Modifier.Node
예) 원을 그리는 Custom Modifier
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawCircle(color)
}
}
Modifier의 필요에 따라서 기능을 재정의 가능
Node | Usage | Sample Link |
---|---|---|
LayoutModifierNode |
Wrapping된 콘텐츠를 측정/배치하는 방식을 변경 | Sample |
DrawModifierNode |
Layout 공간에 그리기 | Sample |
CompositionLocalConsumerModifierNode |
Modifier.Node에서 CompositionLocal을 읽기 가능 | Sample |
SemanticsModifierNode |
테스트, 접근성에서 사용 가능한 semantics key/value를 추가 | Sample |
PointerInputModifierNode |
PointerInputChanges를 수신 | Sample |
ParentDataModifierNode |
부모 레이아웃에 데이터를 제공 | Sample |
LayoutAwareModifierNode |
onMeasured/onPlaced 콜백을 수신 | Sample |
GlobalPositionAwareModifierNode |
콘텐츠의 Global 위치가 변경되었을 때 레이아웃의 최종 레이아웃 좌표가 포함된 onGloballyPositioned 콜백을 수신 | Sample |
ObserverModifierNode |
ObserverNode를 구현하는 Modifier.Node observeReads 블록 내에서 읽은 스냅샷 객체의 변경시에 대한 응답으로 호출되는 onObservedReadsChanged의 자체 구현을 제공 가능 |
Sample |
DelegatingNode |
다른 Modifier.Node 인스턴스에 작업을 위임할 수 있는 Modifier.Node 여러 노드 구현을 하나로 구성하는 데 유용 |
Sample |
TraversableNode |
Modifier.Node 클래스가 동일한 유형의 클래스 또는 특정 키에 대해 노드 트리를 위/아래로 탐색 | Sample |
ModifierNodeElement
커스텀 Modifier를 생성/업데이트하기 위한 데이터를 보관하는 불변 클래스
예) Node의 색상을 변경
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) {
node.color = color
}
}
Modifier factory
Modifier의 공용 API
fun Modifier.circle(color: Color) = this then CircleElement(color)
fun Modifier.fixedPadding() = this then FixedPaddingElement
data object FixedPaddingElement : ModifierNodeElement<FixedPaddingNode>() {
override fun create() = FixedPaddingNode()
override fun update(node: FixedPaddingNode) {}
}
class FixedPaddingNode : LayoutModifierNode, Modifier.Node() {
private val PADDING = 16.dp
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val paddingPx = PADDING.roundToPx()
val horizontal = paddingPx * 2
val vertical = paddingPx * 2
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
placeable.place(paddingPx, paddingPx)
}
}
}
Modifier.Node Modifier는 CompositionLocal과 같은 Composition 상태 객체의 변경 사항을 자동으로 관찰하지 않는다.
CompositionLocal 변경에 자동으로 반응하려면 Scope 내에서 현재 값을 읽어야 한다
예) LocalContentColor의 값을 관찰하여 해당 색상에 따라 배경을 그린다. ContentDrawScope는 스냅샷 변경 사항을 관찰하므로 LocalContentColor 값이 변경되면 자동으로 다시 그린다
class BackgroundColorConsumerNode :
Modifier.Node(),
DrawModifierNode,
CompositionLocalConsumerModifierNode {
override fun ContentDrawScope.draw() {
val currentColor = currentValueOf(LocalContentColor)
drawRect(color = currentColor)
drawContent()
}
}
Modifier.scrollable에서 LocalDensity의 변화를 관찰
class ScrollableNode :
Modifier.Node(),
ObserverModifierNode,
CompositionLocalConsumerModifierNode {
// Place holder Behavior는 작은 밀도를 사용할 수 있을 때 초기화
val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))
override fun onAttach() {
updateDefaultFlingBehavior()
observeReads { currentValueOf(LocalDensity) } // Density 변경 모니터링
}
override fun onObservedReadsChanged() {
// Density가 변경되면 defaultFlingBehavior를 업데이트
updateDefaultFlingBehavior()
}
private fun updateDefaultFlingBehavior() {
val density = currentValueOf(LocalDensity)
defaultFlingBehavior.flingDecay = splineBasedDecay(density)
}
}
CoroutineScope에 접근하여 Animatable Composable API를 사용 가능
예) fade in/fade out을 반복하는 CircleNode를 수정
class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
private val alpha = Animatable(1f)
override fun ContentDrawScope.draw() {
drawCircle(color = color, alpha = alpha.value)
drawContent()
}
override fun onAttach() {
coroutineScope.launch {
alpha.animateTo(
0f,
infiniteRepeatable(tween(1000), RepeatMode.Reverse)
) {
}
}
}
}
Modifier.Node Modifier는 다른 노드에 위임 가능. Modifier 간에 공통 상태를 공유하는 데에도 사용 가능
예) 상호작용 데이터를 공유하는 클릭 가능한 Modifier 노드의 기본 구현
class ClickableNode : DelegatingNode() {
val interactionData = InteractionData()
val focusableNode = delegate(
FocusableNode(interactionData)
)
val indicationNode = delegate(
IndicationNode(interactionData)
)
}
해당 ModifierNodeElement 호출이 업데이트되면 자동으로 무효화된다. 복잡한 Modifier에서는 선택 해제하여 Modifier가 단계를 무효화하는 시점을 보다 세밀하게 제어 가능
Custom Modifier가 레이아웃과 그리기를 모두 수정하는 경우 유용. 자동 무효화를 선택 해제하면 색상과 같은 그리기 관련 속성만 변경될 때 그리기만 무효화하고 레이아웃은 무효화하지 않을 수 있습니다. Modifier의 성능이 향상된다.
예) 색상, 크기 및 onClick 람다를 속성으로 가진 Modifier이며 필요한 것만 무효화하고 필요하지 않은 무효화는 건너뛴다
class SampleInvalidatingNode(
var color: Color,
var size: IntSize,
var onClick: () -> Unit
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode {
override val shouldAutoInvalidate: Boolean
get() = false
private val clickableNode = delegate(
ClickablePointerInputNode(onClick)
)
fun update(color: Color, size: IntSize, onClick: () -> Unit) {
if (this.color != color) {
this.color = color
// 색상이 변결될 때 Draw를 무효화
invalidateDraw()
}
if (this.size != size) {
this.size = size
// 사이즈가 변경될 때 Layout을 무효화
invalidateMeasurement()
}
// onClick만 변경되면 아무 것도 무효화하지 않음
clickableNode.update(onClick)
}
override fun ContentDrawScope.draw() {
drawRect(color)
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val size = constraints.constrain(size)
val placeable = measurable.measure(constraints)
return layout(size.width, size.height) {
placeable.place(0, 0)
}
}
}
개인적으로 자주 쓰일 것으로 보이는 항목만 남김
Action
Alignment
Animation
Border : element의 border 적용
Drawing
Graphics
Layout
Padding
Position
Semantics
Scroll
Size
Testing
Transformations
Other
콘텐츠를 좌우/상하로 넘기려면 HorizontalPager/VerticalPager Composable을 사용. ViewPager와 유사
Pager는 아직 실험적 기능
HorizontalPager
val pagerState = rememberPagerState(pageCount = {
10
})
HorizontalPager(state = pagerState) { page ->
Text(
text = "Page: $page",
modifier = Modifier.fillMaxWidth()
)
}
VerticalPager
val pagerState = rememberPagerState(pageCount = {
10
})
VerticalPager(state = pagerState) { page ->
Text(
text = "Page: $page",
modifier = Modifier.fillMaxWidth()
)
}
PagerState
객체 생성 후, Pager의 state로 전달val pagerState = rememberPagerState(pageCount = {
10
})
HorizontalPager(state = pagerState) { page ->
Text(
text = "Page: $page",
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
)
}
// scroll to page
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
coroutineScope.launch {
// Call scroll to on pagerState
pagerState.scrollToPage(5)
}
}, modifier = Modifier.align(Alignment.BottomCenter)) {
Text("Jump to Page 5")
}
PagerState 속성
snapshotFlow 함수를 사용하여 변수의 변경 사항을 관찰하여 대응 가능
val pagerState = rememberPagerState(pageCount = {
10
})
LaunchedEffect(pagerState) {
// currentPage를 읽는 snapshotFlow에서 수집
snapshotFlow { pagerState.currentPage }.collect { page ->
// 페이지시 특정 작업을 실행
Log.d("Page change", "Page changed to $page")
}
}
VerticalPager(
state = pagerState,
) { page ->
Text(text = "Page: $page")
}
PageState를 사용해 page indicator 그리는데 사용한다
val pagerState = rememberPagerState(pageCount = {
4
})
/** HorizontalPager */
// 커스텀 page indicator
Row(...) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray
Box(...)
}
}
PagerState.currentPageOffsetFraction : 현재 선택된 페이지에서 얼마나 멀리 떨어져 있는지 알려줌 (-0.5 ~ 0.5)
val pagerState = rememberPagerState(pageCount = {
4
})
HorizontalPager(state = pagerState) { page ->
Card(
Modifier
.size(200.dp)
.graphicsLayer {
// 스크롤 위치에서 현재 페이지의 절대 오프셋을 계산
// 양방향의 모든 효과를 미러링 할 수 있는 절대값을 사용
val pageOffset = (
(pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
).absoluteValue
// 알파를 50% ~ 100% 사이로 애니메이션 처리
alpha = lerp(
start = 0.5f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
)
}
) {
// Card content
}
}
기본 각 페이지는 전체 너비/높이를 차지.
val pagerState = rememberPagerState(pageCount = {
4
})
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(100.dp)
) { page ->
// page content
}
ViewPort 기준으로 페이지 크기를 조정
private val threePagesPerViewport = object : PageSize {
override fun Density.calculateMainAxisPageSize(
availableSpace: Int,
pageSpacing: Int
): Int {
return (availableSpace - 2 * pageSpacing) / 3
}
}
콘텐츠의 padding 변경을 지원
val pagerState = rememberPagerState(pageCount = {
4
})
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 32.dp),
) { page ->
// page content
}
pagerSnapDistance 또는 flingBehaviour와 같은 기본값을 변경 가능
Snap distance
기본적으로 fling 제스처로 스크롤할 수 있는 최대 페이지 수를 한 번에 한 페이지로 설정.
val pagerState = rememberPagerState(pageCount = { 10 })
val fling = PagerDefaults.flingBehavior(
state = pagerState,
pagerSnapDistance = PagerSnapDistance.atMost(10)
)
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(200.dp),
beyondBoundsPageCount = 10,
flingBehavior = fling
) {
PagerSampleItem(page = it)
}
}
FlowRow/FlowColumn는 아직 실험적 기능
@Composable
private fun FlowRowSimpleUsageExample() {
FlowRow(modifier = Modifier.padding(8.dp)) {
ChipItem("Price: High to Low")
ChipItem("Avg rating: 4+")
ChipItem("Free breakfast")
ChipItem("Free cancellation")
ChipItem("£50 pn")
}
}
첫 번째 행에 더 이상 공간이 없을 때, 자동으로 다음 행으로 이동하는 UI
main axis는 항목이 배치되는 축이며 배열을 다양하게 설정 가능
예) FlowRow의 일부 배열 케이스
FlowRow에 설정된 가로 배열 | Result |
---|---|
Arrangement.Start (Default ) |
|
Arrangement.Center |
|
Arrangement.spacedBy(8.dp) |
Cross axis는 main axis와 반대 방향의 축
예) FlowRow의 일부 케이스
FlowRow에 설정된 세로 배열 | Result |
---|---|
Arrangement.Top (Default ) |
|
Arrangement.Center |
row 내에서 개별 항목을 서로 다른 정렬로 배치할 때 Modifier.align()
을 사용하여 적용
Vertical alignment set on FlowRow | Result |
---|---|
Alignment.Top (Default ) |
|
Alignment.CenterVertically |
maxItemsInEachRow/maxItemsInEachColumn 파라미터로 main axis에서 한 줄에 허용할 수 있는 최대 항목을 정의. 기본값은 Int.MAX_INT
기본 값 | maxItemsInEachRow = 3 |
---|---|
ContextualFlowRow/ContextualFlowColumn은 FlowRow/FlowColumn의 콘텐츠를 지연 로드할 수 있는 특수 버전
예) ‘+(남은 항목 수)’ 또는 ‘Less’ 버튼을 표시
val totalCount = 40
var maxLines by remember {
mutableStateOf(2)
}
val moreOrCollapseIndicator = @Composable { scope: ContextualFlowRowOverflowScope ->
val remainingItems = totalCount - scope.shownItemCount
ChipItem(if (remainingItems == 0) "Less" else "+$remainingItems", onClick = {
if (remainingItems == 0) {
maxLines = 2
} else {
maxLines += 5
}
})
}
ContextualFlowRow(
...
maxLines = maxLines,
overflow = ContextualFlowRowOverflow.expandOrCollapseIndicator(
minRowsToShowCollapse = 4,
expandIndicator = moreOrCollapseIndicator,
collapseIndicator = moreOrCollapseIndicator
),
itemCount = totalCount
) { index ->
ChipItem("Item $index")
}
항목의 갯수와 사용 가능한 공간에 따라 항목을 늘린다.
각 항목 너비 : 가중치 * (남은 공간 / 총 가중치)
Modifier.weight와 최대 항목 수를 조합하여 Grid 레이아웃을 만들 수 있다.
val rows = 3
val columns = 3
FlowRow(
...
maxItemsInEachRow = rows
) {
val itemModifier = Modifier
.padding(4.dp)
.height(80.dp)
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(MaterialColors.Blue200)
repeat(rows * columns) {
Spacer(modifier = itemModifier)
}
}
위의 예시에서 10개의 항목이라면, 전체 행의 총 가중치가 1f가 되므로 마지막 항목이 마지막 열 전체를 차지한다
Modifier.fillMaxWidth(fraction)를 사용하면 항목이 차지할 크기를 지정 가능
예) FlowRow/Row를 사용할 때 다른 결과의 모습
FlowRow(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
maxItemsInEachRow = 3
) {
val itemModifier = Modifier
.clip(RoundedCornerShape(8.dp))
Box(modifier = itemModifier.height(200.dp).width(60.dp).background(Color.Red))
Box(modifier = itemModifier.height(200.dp).fillMaxWidth(0.7f).background(Color.Blue))
Box(modifier = itemModifier.height(200.dp).weight(1f).background(Color.Magenta))
}
FlowRow: 전체 컨테이너 너비의 0.7분의 1을 차지하는 중간 항목 | |
---|---|
Row: 중간 항목이 남은 행 너비의 0.7%를 차지 |
fillMaxColumnWidth()/fillMaxRowHeight()
같은 Column/Row의 항목이 Column/Row에서 가장 큰 항목과 동일한 너비/높이를 가지도록 함
각 항목에 적용된 Modifier.fillMaxColumnWidth()
UI 트리에 각 노드를 배치하는 작업은 3단계 프로세스. 각 노드는 다음을 수행해야 함
Compose UI는 복수 pass 측정을 허용하지 않음. 즉, 레이아웃 element는 다른 측정 구성을 시도하기 위해 하위 element를 두 번 이상 측정할 수 없다.
Scope의 사용 여부에 따라 하위 element를 측정/배치할 수 있는 시기가 결정됨.
layout
Modifier를 사용하여 element의 측정/배치 방식을 수정layout
은 람다이며, measurable로 전달되는 측정 가능한 element와 constraints로 전달되는 해당 Composable의 제약 조건이 파라미터로 포함fun Modifier.customLayoutModifier() =
layout { measurable, constraints ->
// ...
}
(이미지 샘플 예시)
화면에 텍스트를 표시, 텍스트의 첫 번째 줄의 상단에서 baseline까지의 거리를 제어. layout
modifier를 사용하여 composable을 수동으로 배치
measurable
람다 파라미터에서 measurable.measure(constraints)
를 호출하여 측정 가능한 파라미터로 표시되는 Text
를 측정layout(width, height)
메서드를 호출하여 컴포저블의 크기를 지정. 크기는 마지막 기준선과 추가된 상단 패딩 사이의 높이placeable.place(x, y)
를 호출하여 화면에 래핑된 요소를 배치. element를 배치하지 않으면 표시되지 않습니다. (y
위치는 상단 패딩 - 텍스트의 첫 번째 기준선 위치)fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = layout { measurable, constraints ->
// composable 측정
val placeable = measurable.measure(constraints)
// composable에 FirstBaseline이 있는지 체크
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// padding이 포함된 composable의 높이 - firstBaseline
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
// composable이 배치되는 위치
placeable.placeRelative(0, placeableY)
}
}
// 다음과 같이 Modifier를 사용
@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
MyApplicationTheme {
Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
}
}
layout
modifier는 호출하는 Composable만 변경. 여러 Composable을 측정하고 배치에 사용.
하위 element를 수동으로 측정/배치 가능.
row/column과 같은 상위 수준 레이아웃은 Laytout composable로 만든다
// 예) 기본적인 버전의 Column
@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 여기에서 제약 조건 로직이 주어진 하위 element를 측정하고 배치
// 측정된 하위 element 목록
val placeables = measurables.map { measurable ->
// 하위 element 측정
measurable.measure(constraints)
}
// 레이아웃의 크기를 최대한 크게 설정
layout(constraints.maxWidth, constraints.maxHeight) {
// 하위 element를 최대로 배치한 Y 좌표를 추적
var yPosition = 0
// 부모 layout에 하위 element 배치
placeables.forEach { placeable ->
// 화면에서 item 위치 지정
placeable.placeRelative(x = 0, y = yPosition)
// 배치된 Y 좌표를 업데이트
yPosition += placeable.height
}
}
}
}
@Composable
fun CallingComposable(modifier: Modifier = Modifier) {
MyBasicColumn(modifier.padding(8.dp)) {
Text("MyBasicColumn")
Text("places items")
Text("vertically.")
Text("We've done it by hand!")
}
}
Composable의 레이아웃 방향은 LocalLayoutDirection CompositionLoal로 변경하면 바꿀 수 있다.
화면에 수동으로 배치하는 경우, LayoutDirection은 layout
modifier 또는 Layout composable의 LayoutScope에 포함됨
layoutDirection
사용 시 Composable을 배치할 때에는 place를 사용.
placeRelative 메소와 달리 place는 레이아웃 방향(왼쪽에서 오른쪽 또는 오른쪽에서 왼쪽)에 따라 변경되지 않음
paddingFrom : 라이브러리에서 alignment line을 기준으로 padding을 지정 가능
커스텀 Layout Composable 또는 커스텀 LayoutModifier를 만들 때 커스텀 alignment lines을 제공하면 다른 부모 Composable이 이를 사용하여 하위 element를 정렬/배치할 수 있다.
차트의 최대/최소 데이터 값에 맞춰 정렬할 수 있도록 두 개의 Alignment lines인 MaxChartValue와 MinChartValue를 노출하는 커스텀 BarChart Composable. 세로로 정렬하는 데 사용되므로 HorizontalAlignmentLine 유형을 사용한다.
/** BarChart의 최대 값으로 정의된 AlignmentLine */
private val MaxChartValue = HorizontalAlignmentLine(merger = { old, new ->
min(old, new)
})
/** BarChart의 최소 값으로 정의된 AlignmentLine */
private val MinChartValue = HorizontalAlignmentLine(merger = { old, new ->
max(old, new)
})
@Composable
private fun BarChart(
dataPoints: List<Int>,
modifier: Modifier = Modifier,
) {
val maxValue: Float = remember(dataPoints) { dataPoints.maxOrNull()!! * 1.2f }
BoxWithConstraints(modifier = modifier) {
val density = LocalDensity.current
with(density) {
// ...
// Calculate baselines
val maxYBaseline = // ...
val minYBaseline = // ...
Layout(
content = {},
modifier = Modifier.drawBehind {
// ...
}
) { _, constraints ->
with(constraints) {
layout(
width = if (hasBoundedWidth) maxWidth else minWidth,
height = if (hasBoundedHeight) maxHeight else minHeight,
// 커스텀 AlignmentLines이 설정.
// 직간접 부모 Composable에 전파된다
alignmentLines = mapOf(
MinChartValue to minYBaseline.roundToInt(),
MaxChartValue to maxYBaseline.roundToInt()
)
) {}
}
}
}
}
}
이 Composable의 직간접 Composable이 AlignmentLines을 사용할 수 있다. 그다음 Composable은 두 개의 텍스트 슬롯과 데이터 포인트를 파라미터로 사용하여 두 텍스트를 최대/최소 차트 데이터 값에 맞춰 정렬하는 커스텀 레이아웃을 만든다.
@Composable
private fun BarChartMinMax(
dataPoints: List<Int>,
maxText: @Composable () -> Unit,
minText: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
Layout(
content = {
maxText()
minText()
// BarChart 고정 크기 설정
BarChart(dataPoints, Modifier.size(200.dp))
},
modifier = modifier
) { measurables, constraints ->
check(measurables.size == 3)
val placeables = measurables.map {
it.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val maxTextPlaceable = placeables[0]
val minTextPlaceable = placeables[1]
val barChartPlaceable = placeables[2]
// BarChart에서 alignment lines을 가져와 텍스트의 위치를 지정
val minValueBaseline = barChartPlaceable[MinChartValue]
val maxValueBaseline = barChartPlaceable[MaxChartValue]
layout(constraints.maxWidth, constraints.maxHeight) {
maxTextPlaceable.placeRelative(
x = 0,
y = maxValueBaseline - (maxTextPlaceable.height / 2)
)
minTextPlaceable.placeRelative(
x = 0,
y = minValueBaseline - (minTextPlaceable.height / 2)
)
barChartPlaceable.placeRelative(
x = max(maxTextPlaceable.width, minTextPlaceable.width) + 20,
y = 0
)
}
}
}
@Preview
@Composable
private fun ChartDataPreview() {
MaterialTheme {
BarChartMinMax(
dataPoints = listOf(4, 24, 15),
maxText = { Text("Max") },
minText = { Text("Min") },
modifier = Modifier.padding(24.dp)
)
}
}
Compose 규칙 중 하나는 하위 element를 한 번만 측정해야 한다는 것이다. 두 번 측정하면 런타임 예외가 발생한다. 하지만 하위 element 측정하기 전에 하위 element에 관한 정보가 필요한 경우도 있다.
Intrinsics을 사용하면 하위 element가 측정되기 전에 하위 element를 쿼리할 수 있다.
Composable에 intrinsicWidth
/intrinsicHeight
를 요청
(min|max)IntrinsicWidth
: 콘텐츠를 적절하게 그릴 수 있는 최소/최대 너비(min|max)IntrinsicHeight
: 콘텐츠를 적절하게 그릴 수 있는 최소/최대 높이
width
가 무한대인Text
의minIntrinsicHeight
를 요청하면, 텍스트가 한 줄에 그려진 것처럼Text
의height
가 반환
@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
// IntrinsicSize.Min : 최소 고유 높이만큼 하위 element들의 크기를 강제로 조절
Row(modifier = modifier.height(IntrinsicSize.Min)) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1
)
Divider(
color = Color.Black,
modifier = Modifier.fillMaxHeight().width(1.dp)
)
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2
)
}
}
// @Preview
@Composable
fun TwoTextsPreview() {
MaterialTheme {
Surface {
TwoTexts(text1 = "Hi", text2 = "there")
}
}
}
Row의 modifier를 기본값을 사용한다면 Divider의 fillMaxHeight() 조건으로 부모가 가지는 높이만큼 채우게 된다.
Divider
요소의 minIntrinsicHeight
는 제약 조건이 주어지지 않으면 공간을 차지하지 않으므로 0이다. Row
요소의 height
제약 조건은 Text
의 최대 minIntrinsicHeight
입니다. 그런 다음 Divider
가 height
를 Row
가 지정한 height
제약 조건으로 확장한다.
커스텀 Layout 또는 layout modifier를 만들 때 근사치를 기반으로 측정값이 자동으로 계산된다. 모든 레이아웃이 계산이 정확하지 않다. API를 사용해서 재정의 가능하다
Layout의 MeasurePolicy 인터페이스를 재정의하면 된다.
커스텀 layout
modifier은 LayoutModifier 인터페이스를 재정의 구현하면 된다.
기존 Viw 시스템에서
ConstraintLayout
은 크고 복잡한 레이아웃을 만드는 데 권장된 이유는 flat View 계층 구조가 중첩된 View보다 성능 면에서 더 좋았기 때문이다. 그러나, 깊은 레이아웃 계층 구조를 효율적으로 처리할 수 있는 Compose에서는 문제가 되지 않는다.
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
Compose의 ConstraintLayout
은 DSL 방식을 사용
@Composable
fun ConstraintLayoutContent() {
ConstraintLayout {
// 제약할 composable에 대한 레퍼런스를 생성
val (button, text) = createRefs()
Button(
onClick = { /* Do something */ },
// Button composable에 참조 "button"을 할당,
// ConstraintLayout의 상단으로 제약 추가
modifier = Modifier.constrainAs(button) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button")
}
// Text composable에 참조 "text"를 할당,
// Buton composable의 하단에 제약 추가
Text(
"Text",
Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
}
)
}
}
ConstraintLayout의 ConstraintSet과 layoutId를 분리하여 지정 가능
ConstraintSet
을 파라미터로 ConstraintLayout
에 전달layoutId
modifier를 사용하여 ConstraintSet
에 생성된 참조를 Composable에 할당@Composable
fun DecoupledConstraintLayout() {
BoxWithConstraints {
val constraints = if (minWidth < 600.dp) {
decoupledConstraints(margin = 16.dp) // 세로 제약
} else {
decoupledConstraints(margin = 32.dp) // 가로 제약
}
ConstraintLayout(constraints) {
Button(
onClick = { /* Do something */ },
modifier = Modifier.layoutId("button")
) {
Text("Button")
}
Text("Text", Modifier.layoutId("text"))
}
}
}
private fun decoupledConstraints(margin: Dp): ConstraintSet {
return ConstraintSet {
val button = createRefFor("button")
val text = createRefFor("text")
constrain(button) {
top.linkTo(parent.top, margin = margin)
}
constrain(text) {
top.linkTo(button.bottom, margin)
}
}
}
Guidelines
특정 dp
/percentage
로 배치하는 데 사용
ConstraintLayout {
// Composable width의 10%에서 parent start부터 가이드라인 생성
val startGuideline = createGuidelineFromStart(0.1f)
// parent end에서 Composable width의 10%에 가이드라인 생성
val endGuideline = createGuidelineFromEnd(0.1f)
// parent top의 16dp에서 가이드라인 생성
val topGuideline = createGuidelineFromTop(16.dp)
// parent bottom에서 16dp에서 가이드라인 생성
val bottomGuideline = createGuidelineFromBottom(16.dp)
}
Row/Column에서 Spacer composable을 사용하면 비슷한 효과가 가능
Barriers
여러 composable을 참조하여 지정된 쪽의 가장 극단적인 가상 guideline을 생성
ConstraintLayout {
val constraintSet = ConstraintSet {
val button = createRefFor("button")
val text = createRefFor("text")
val topBarrier = createTopBarrier(button, text)
}
}
Row/Column에서 Intrinsic을 사용하면 비슷한 효과가 가능
Chains
단일 축(가로/세로)에서 group과 같은 동작을 제공
ConstraintLayout {
val constraintSet = ConstraintSet {
val button = createRefFor("button")
val text = createRefFor("text")
val verticalChain = createVerticalChain(button, text, chainStyle = ChainStyle.Spread)
val horizontalChain = createHorizontalChain(button, text)
}
}
지원하는 ChainStyles
Row/Column에서 Arrangements을 사용하면 비슷한 효과가 가능
comments powered by Disqus
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024