Layout의 기본 사항을 다룬다. API의 유연성을 보여주기 위해 좀 더 복잡한 앱을 만들어 보겠다. 아래 그림에서 볼 수 있는 커스텀 Material Study Owl에서 지그재그 그리드를 구현해보겠다.

지그재그 그리드는 아이템을 n개의 행이 주어지면 한 번에 열을 채운다. 레이아웃의 비틀림을 얻지 못할 것이기 때문에 Columns의 Row로 수행하는 것을 불가능하다. 데이터가 세로로 표시되도록 준비하면 Rows의 Column이 있을 수 있다.
커스텀 레이아웃을 사용하면 지그재그 그리드에 있는 모든 아이템의 높이를 제한할 수 있다. 따라서, 레이아웃을 더 잘 제어하고 커스텀 레이아웃을 만드는 방법을 익히기 위해 직접 자식들을 measure하고 배치할 것이다.
그리드를 다른 방향에서 재사용할 수 있도록 하려면 화면에 표시하려는 행의 수를 매개변수로 사용할 수 있다. 해당 정보는 레이아웃이 호출될 때 불러와야 하므로 매개변수로 전달한다.
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 이 부분에 제약조건 로직이 주어진 자식을 measure하고 배치
}
}
이전과 마찬가지로 가장 먼저 해야 할 일은 자녀를 measure하는 것이다. 항상 기억해야 할 것은 한 번만 measure할 수 있는 것이다.
자식 view를 더 이상 제한하지 않을것이다. 자식을 measure할 때 각 행의 width와 최대 height도 추적해야 한다.
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 각 행의 너비 추적
val rowWidths = IntArray(rows) { 0 }
// 각 행의 최대 높이 추적
val rowHeights = IntArray(rows) { 0 }
// 자식 view를 더이상 제한하지 말고 주어진 제약조건으로 measure
// measured된 자식들의 리스트
val placeables = measurables.mapIndexed { index, measurable ->
// 각 자식들을 measure
val placeable = measurable.measure(constraints)
// 각 행의 너비와 최대 높이 추적
val row = index % rows
rowWidths[row] += placeable.width
rowHeights[row] = Math.max(rowHeights[row], placeable.height)
placeable
}
...
}
이제 로직에 measure된 자식 리스트가 있으므로 화면에 배치하기 전에 그리드의 크기(전체 width와 height)를 계산해야 한다. 각 행의 최대 높이를 이미 알고 있기 때문에 각 행의 요소를 Y 위치에 배치할 위치를 계산할 수 있다. rowY 변수에 Y 위치를 저장한다.
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
...
// 그리드의 너비는 가장 넓은 row
val width = rowWidths.maxOrNull()
?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth
// 그리드의 높이는 각 row에서 가장 높은 요소의 합
// 높이 제약 조건으로 강제됨
val height = rowHeights.sumOf { it }
.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
// 각 행의 Y는 이전 행의 누적 높이를 기준으로
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i-1] + rowHeights[i-1]
}
...
}
마지막으로 placeable.placeRelative(x, y)를 호출하여 화면에 자식들을 배치한다. 아래에서는 rowX 변수의 각 행에 대한 X 좌표도 추적할 것이다.
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
...
// 부모 레이아웃의 크기 설정
layout(width, height) {
// row마다 배치한 x 좌표
val rowX = IntArray(rows) { 0 }
placeables.forEachIndexed { index, placeable ->
val row = index % rows
placeable.placeRelative(
x = rowX[row],
y = rowY[row]
)
rowX[row] += placeable.width
}
}
}
자식을 measure하고 배치하는 방법을 제시하는 커스텀 그리드 레이아웃을 사용해 보겠다. 그리드에서 Owl의 chip을 시뮬레이션하기 위해 유사한 작업을 수행하는 컴포저블을 만든다.
@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
Card(
modifier = modifier,
border = BorderStroke(color = Color.Black, width = Dp.Hairline),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(16.dp, 16.dp)
.background(color = MaterialTheme.colors.secondary)
)
Spacer(Modifier.width(4.dp))
Text(text = text)
}
}
}
@Preview
@Composable
fun ChipPreview() {
LayoutComposeCodelabTheme {
Chip(text = "Hi there")
}
}

이제 BodyContent에 표시할 수 있는 topic list를 만들고 StaggeredGrid에 표시해 보겠다.
val topics = listOf(
"Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
"Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
"Religion", "Social sciences", "Technology", "TV", "Writing"
)
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
StaggeredGrid(modifier = modifier) {
for (topic in topics) {
Chip(modifier = Modifier.padding(8.dp), text = topic)
}
}
}

그리드의 행 개수를 변경해도 무리없이 작동한다.
StaggeredGrid(modifier = modifier, rows = 5)
행의 수에 따라 topic이 화면 밖으로 나갈 수 있으므로 StaggeredGrid를 스크롤 가능한 Row에 래핑하고 StaggeredGrid 대신 수정자를 전달해 BodyContent를 스크롤 가능하게 만들 수 있다.
Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
StaggeredGrid {
...
수정자의 기본 사항, 커스텀 컴포저블을 만들고 자식을 수동으로 measure하고 배치하는 방법을 배웠다. 수정자가 내부에서 어떻게 작동하는지 이해하는데 도움이 됐을 것이다.
다시 말해, 수정자를 사용하면 컴포저블의 동작을 커스터마이징할 수 있다는 것이다. 여러 수정자를 결합할 수 있다.
이번에는 UI 구성요소가 measure되고 배치되는 방식을 변경할 수 있는 LayoutModifier를 알아볼 것이다.
컴포저블은 자체 콘텐츠에 대한 책임이 있으며 해당 컴포저블의 작성자가 명시적 API를 노출하지 않는 한 부모가 해당 콘텐츠를 검사하거나 조작할 수 없다. 이와 유사하게, 컴포저블의 수정자는 수정하는 것을 동일한 불투명한 방식으로 한다. 이때 수정자는 캡슐화된다.
Modifier와 LayoutModifier는 public 인터페이스이므로 고유한 수정자를 만들 수 있다. 에전에 Modifier.padding을 사용한 것처럼 수정자를 이해하기 위해 구현을 분석해보겠다.
padding은 LayoutModifier 인터페이스를 구현하는 클래스에 의해 백업되는 함수이며 measure 메서드를 재정의할 것이다. PaddingModifier는 equals()를 구현하는 일반 클래스이므로 재구성에서 비교할 수 있다.
예를 들어, padding이 요소의 크기와 제약조건을 수정하는 방법은 아래 코드와 같다.
// modifier 생성
@Stable
fun Modifier.padding(all: Dp) =
this.then(
PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
)
// 디테일 구현
private class PaddingModifier(
val start: Dp = 0.dp,
val top: Dp = 0.dp,
val end: Dp = 0.dp,
val bottom: Dp = 0.dp,
val rtlAware: Boolean,
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val horizontal = start.roundToPx() + end.roundToPx()
val vertical = top.roundToPx() + bottom.roundToPx()
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) {
if (rtlAware) {
placeable.placeRelative(start.roundToPx(), top.roundToPx())
} else {
placeable.place(start.roundToPx(), top.roundToPx())
}
}
}
}
요소의 새 width = 자식의 width + 요소의 너비 제약조건으로 강제 변환된 start와 end 패딩 값
요소의 새 height = 자식의 height + 요소의 높이 제약조건으로 강제 변환된 top과 bottom 패딩 값
수정자 분석 파트에서 보았듯이 modifier가 수정하는 컴포저블에 적용되므로 순서가 중요하다. 즉, 왼쪽 수정자의 measure와 레이아웃이 오른쪽 수정자에 영향을 준다. 컴포저블의 최종 크기는 매개변수로 전달된 모든 수정자에 따라 달라진다.
수정자는 제약조건을 왼쪽 -> 오른쪽으로 갱신하고 오른쪽 -> 왼쪽으로 크기를 반환한다.
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(color = Color.LightGray)
.size(200.dp)
.padding(16.dp)
.horizontalScroll(rememberScrollState())
)
이러한 방식으로 적용된 수정자의 결과는

먼저 배경을 변경해 수정자가 UI에 미치는 영향을 확인한 다음 크기를 width와 height가 200.dp로 제한하고 마지막으로 패딩을 적용해 텍스트와 주변 환경 사이에 약간의 공간을 추가한다.
제약 조건은 체인을 통해 왼쪽 -> 오른쪽으로 전파되기 때문에 measure할 Row의 내용에 사용되는 제약 조건은 최소/최대 width와 height 모두에 대해 (200-16-16)=168dp이다. 이건 StaggeredGrid의 크기가 정확히 168x168dp가 된다는 걸 의미한다. 따라서 modifySize 체인이 오른쪽 -> 왼쪽으로 실행된 후 스크롤 가능한 Row의 최종 크기는 200x200dp가 된다.
수정자의 순서를 변경하면 패딩을 먼저 적용한 다음 크기를 적용해 다른 UI가 표시된다.
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(color = Color.LightGray, shape = RectangleShape)
.padding(16.dp)
.size(200.dp)
.horizontalScroll(rememberScrollState())
)
이 경우 스크롤 가능한 Row와 padding이 원래 가지고 있던 제약 조건은 자식을 measure하기 위해 size 제약 조건으로 강제 변환된다. 따라서, StaggeredGrid는 최소/최대 width와 height 모두에 대해 200dp로 제한된다. StaggeredGrid 크기는 200x200이고 크기가 오른쪽 -> 왼쪽으로 수정됨에 따라 padding 수정자는 크기를 (200+16+16)x(200+16+16)=232x232)로 증가시켜 Row을 포함해 최종 크기가 된다.
LayoutDirection 앰비언트를 사용해 컴포저블의 레이아웃 방향을 변경할 수 있다.
컴포저블을 화면에 수동으로 배치하는 경우 layoutDirection은 layout 수정자나 Layout 컴포저블의 LayoutScope의 일부이다. layoutDirection을 사용할 때 placeRelative 메서드와 달리 place를 사용하여 컴포저블을 배치하면 오른쪽 -> 왼쪽 컨텍스트의 위치가 자동으로 미러링되지 않는다.
ConstraintLayout은 컴포저블을 화면의 다른 항목에 상대적으로 배치하는 데 도움이 되며 여러 Row, Column, Box를 대신할 수 있다. ConstraintLayout은 더 복잡한 정렬이 요구될 때 유용하다.
View 시스템에서
ConstraintLayout은 성능면에서 Flat 뷰 계층 구조가 더 조힉 때문에 크고 복잡한 레이아웃을 만드는 데 권장되는 방법이었다. 그러나 Compose에서는 deep 레이아웃 계층을 효율적으로 처리할 수 있어 문제가 되지 않는다.
build.gradle 파일에 ConstraintLayout dependency를 추가해 사용한다.
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
Compose의 ConstraintLayout은 DSL과 함께 작동하는데,
createRefs()나 createRef()를 통해 생성되며 ConstraintLayout의 각 컴포저블에는 연결된 Reference가 있어야 한다.constrainAs 수정자를 사용해야 한다.linktTo나 다른 메서드를 사용해서 지정한다.ㅌparent는 ConstraintLayout 컴포저블 자체에 대한 제약 조건을 지정하는 데 사용할 수 있는 기존 reference이다.예시를 만들어보자
@Composable
fun ConstraintLayoutContent() {
ConstraintLayout() {
// 컴포저블 제약을 위해 reference 생성
val (button, text) = createRefs()
Button(
onClick = { },
// Button 컴포저블에 reference "button" 할당
// ConstraintLayout의 top에 제한
modifier = Modifier.constrainAs(button) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button")
}
// Text 컴포저블에 reference "text 할당
// Button 컴포저블의 bottom에 제한
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
})
}
}
@Preview
@Composable
fun ConstraintLayoutContentPreview() {
LayoutComposeCodelabTheme {
ConstraintLayoutContent()
}
}
이렇게 하면 Button의 top이 16dp의 margin을 가진 부모로 제한되고 Text도 16dp의 margin이 있는 Button의 bottom으로 제한된다.

텍스트를 가로+중앙에 맞추려면 Text의 start와 end를 parent의 가장자리로 설정하는 centerHorizontallyTo 함수를 사용하면 된다.
@Composable
fun ConstraintLayoutContent() {
ConstraintLayout {
... // 이전과 동일
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
// ConstraintLayout 안에서 Text를 가로 중앙 정렬
centerHorizontallyTo(parent)
})
}
}
ConstraintLayout의 크기는 콘텐츠를 래핑할 수 있도록 가능한 최대로 작아진다. 그래서 Text가 부모 대신 Button 중심에 있다. 다른 크기 조정 동작이 필요한 경우 크기 수정자(ex. fillMaxSize, size)는 Compose의 다른 레이아웃과 마찬가지로 ConstraintLayout 컴포저블에 적용되어야 한다.
DSL은 guideline, barrier, chain 생성을 지원한다. 예를 들어,
@Composable
fun ConstraintLayoutContent() {
ConstraintLayout() {
// 컴포저블 제약을 위해 3개의 reference를 ConstraintLayout body 안에 생성
val (button1, button2, text) = createRefs()
Button(
onClick = { },
// Button 컴포저블에 reference "button" 할당
// ConstraintLayout의 top에 제한
modifier = Modifier.constrainAs(button1) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button 1")
}
// Text 컴포저블에 reference "text 할당
// Button 컴포저블의 bottom에 제한
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button1.bottom, margin = 16.dp)
// ConstraintLayout 안에서 Text를 가로 중앙 정렬
centerAround(button1.end)
})
val barrier = createEndBarrier(button1, text)
Button(
onClick = { },
modifier = Modifier.constrainAs(button2) {
top.linkTo(parent.top, margin = 16.dp)
start.linkTo(barrier)
}
) {
Text("Button 2")
}
}
}

ConstraintLayout의 body 안에서 만들 수 있지만 constrainAs 내부에서는 만들 수 없다.linkTo는 레이아웃의 가장자리에서 작동하는 것과 같은 방식으로 guidline과 barrier를 제한하는 데 사용할 수 있다.기본적으로 ConstraintLayout의 자식은 콘텐츠를 래핑하는 데 필요한 크기를 선택할 수 있다. 예를 들어, Text가 너무 길 때 Text가 화면 경계를 벗어날 수 있음을 의미한다.
@Composable
fun LargeConstraintLayout() {
ConstraintLayout {
val text = createRef()
val guideline = createGuidelineFromStart(fraction = 0.5f)
Text(
"This is a very very very very very very very long text",
Modifier.constrainAs(text) {
linkTo(start = guideline, end = parent.end)
}
)
}
}

분명히 사용 가능한 공간에서 텍스트를 줄바꿈하기를 원한다. 이를 위해 텍스트의 width를 변경할 수 있다. 아래 코드를 linkTo() 밑에 추가한다.
width = Dimension.preferredWrapContent

사용 가능한 Dimension
preferredWrapContent : 레이아웃은 해당 차원의 제약 조건이 적용되는 랩 콘텐츠wrapContent : 제약 조건에서 허용하지 않는 경우에도 레이아웃은 랩 콘텐츠fillToConstraints : 해당 차원의 제약 조건에 의해 정의된 공간을 채우도록 레이아웃 확장preferredValue : 레이아웃은 해당 차원의 제약 조건에 따라 고정된 dp값value : 레이아웃은 해당 차원의 제약 조건에 관계없이 고정 dp값특정 차원은 아래처럼 강제 변환될 수 있다.
width = Dimension.preferredWrapContent.atLeast(100.dp)
지금까지 제약 조건이 적용되는 컴포저블의 수정자와 함께 inline으로 지정되었다. 그러나 제약 조건이 적용되는 레이아웃에서 분리된 상태로 유지하는 것이 중요한 경우가 있다.
이러한 경우 다른 방식으로 ConstraintLayout을 사용할 수 있다.
1. ConstraintSet을 ConstraintLayout에 매개변수로 전달
2. layoutId 수정자를 사용하여 ConstraintSet에서 생성된 reference를 컴포저블에 할당
첫 번째 ConstraintLayout 예쩨에 적용된 이 API shape는 화면 너비에 최적화되어 아래와 같다.
@Composable
fun DecoupledConstraintLayout() {
BoxWithConstraints {
val constraints = if (maxWidth < maxHeight) {
decoupledConstraints(margin = 16.dp) // Portrait constraints
} else {
decoupledConstraints(margin = 32.dp) // Landscape constraints
}
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)
}
}
}
앞서 배운 Compose의 규칙 중 하나는 자식을 한 번만 measure해야 한다는 것이다. 그렇지 않을 경우 Runtime Exception이 발생한다. 그러나 자식을 measure하기 전 자식에 대한 정보가 필요할 때가 있다.
Intrinsics를 사용하면 실제로 measure되기전 자식을 쿼리할 수 있다.
컴포저블에 intrinsicWidth나 intrinsicHeight를 요청할 수 있다.
(min|max)IntrinsicWidth : 높이가 주어지면 콘텐츠를 적절하게 paint할 수 있는 최소/최대 너비(min|max)IntrinsicHeight : 너비가 주어지면 콘텐츠를 적절하게 paint할 수 있는 최소/최대 높이예를 들어, 무한한 width의 Text에 minInstrinsicHeight를 요청하면 Text가 한 라인에 그려진 것처럼 Text의 height가 반환된다.
아래 그림과 같이 구분선으로 구분된 화면에 2개의 텍스트를 표시하는 컴포저블을 만들어 보자.

그림을 보면 최대로 확장되는 2개의 Text가 있는 Row와 중간에 Divider를 가진다. 여기서 Divider가 가장 큰 Text만큼 크고 너비는 width = 1.dp로 고정한다.
@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier) {
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(start = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2
)
}
}

그런데 이 코드를 실행해보면 Divider 구분선이 화면 끝까지 그려져 있다. 우리가 의도한 바와는 다르다. 이런 결과가 나오는 이유는 Row가 자식을 개별 measure하고 Text의 높이를 Divider를 제한하는 데 사용할 수 없기 때문에 발생한다.
우리는 Divider가 주어진 높이로 사용 가능한 공간을 채우길 원한다. 이를 위해 height(IntrinsicSize.Min) 수정자를 사용할 것이다.
height(IntrinsicSize.Min)은 최소 고유 높이만큼 커지도록 강제되는 자식의 크기를 조정한다. 재귀이기 때문에 Row와 그 자식 minIntrinsicHeight를 쿼리한다.
Row(modifier = modifier.height(IntrinsicSize.Min))

Row의 minIntrinsicHeight는 자식의 최대 minIntrinsicHeight가 된다. Divider의minIntrinsicHeight는 제약 조건이 주어지지 않으면 공간을 차지하지 않으므로 0이다. Text의 minIntrinsicHeight는 특정 width가 지정된 Text의 것이다. 따라서, Row의 height 제약 조건은 Text의 최대 minIntrinsicHeight가 된다. 그 후 Divider는 Row에서 지정한 height 제약 조건으로 height를 확장한다.
커스텀 레이아웃을 생성할 때마다 MeasurePolicy 인터페이스의 (min|max)Intrinsic(Width|Height)를 사용해 내장 함수를 계산하는 방법을 수정할 수 있다. 그러나 대부분의 경우에는 기본값으로 해결된다.
또한, 유용한 기본값을 가지고 있는 Modifier 인터페이스의 Density.(min|max)Intrinsic(Width|Height)Of 메서드를 재정의하는 수정자로 내장 함수를 수정할 수 있다.