이번 글에선 Compose 를 사용해서 Staggered Grid Layout 을 구현해볼 것이다. Compose 에서 Lazy Vertical Grid를 지원하도록 많이 노력 중이지만, 글을 작성하는 시점(2022년 1월 23일) 으로 아직까지 안정화가 되지 않았고, 구글에선 Column 이나 Row 를 사용해 동일한 결과를 얻도록 권장하고 있는 상황이다.
Staggered Grid Layout 는 다음과 같은 격자 형태의 레이아웃을 의미한다.
Compose Layout 에 대해 이해하기
우선 Staggered Gird Layout 을 만들려면, Compose 의 ViewGroup 역할을 하는 Layout 에 대해서 먼저 이해해야 한다.
컴포즈의 ViewGroup에서는 다음과 같은 순서를 통해 UI 트리에 각 노드를 배치한다.
- 모든 하위 요소 (Composable) 측정
- 자체 크기 결정
- 하위 요소 배치
즉 content 에 해당하는 하위 요소들의 크기를 먼저 측정한 뒤에, 부모 역할을 하는 자기 자신의 크기를 측정한다. 그리고 x,y 좌표를 이용해 하위 Composable 요소들을 배치하는 것이다.
이해를 좀 더 쉽게 하기 위해 직접 Column 을 구현해서 설명해보겠다.
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
var width = 0
//자식을 측정하는 부분 -> 자식들의 크기를 측정하고, 자식의 최대 넓이를 저장한다.
val placeables = measurables.map { measurable ->
val placeable = measurable.measure(constraints = constraints)
width = max(width, placeable.width)
placeable
}
//이 레이아웃의 크기는, 자식의 최대 넓이로 지정한다.
var y = 0
layout(width = width, constraints.maxHeight) {
for (placeable in placeables) {
placeable.placeRelative(0, y)
y += placeable.height
}
}
}
}
1. Layout
레이아웃은 modifier 와 content, 그리고 measurePolicy를 매개변수로 받는다. 이 때 content 는 해당 layout 안에 자식으로, Layout 의 선언부를 보면 다음과 같이 작성되었다.
@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
measurePolicy 는 자식을 어떻게 배치할지 결정하는 함수로, measurable (측정할 자식) 과 constraint(Composable 의 제약 조건) 를 매개변수로 받는다.
이제 measurable 과 constaint로 자식뷰의 사이즈를 측정할 수 있다.
val placeables = measurables.map { measurable ->
val placeable = measurable.measure(constraints = c)
width = max(width, placeable.width)
placeable
}
이제 measurable과 constaint를 가지고 자식뷰를 측정하면 자식의 크기정보를 담고 있는 Placeable 객체가 반환된다.
자식 뷰는 여러개가 올 수 있기 때문에 map으로 Iterable 형태로 저장해준다. 여기서 현재 layout 의 넓이를 가장 넓은 자식의 width 로 설정하기 위해 가장 큰 값을 width 에 저장한다.
2. 자식 뷰 배치하기
이제 자식의 크기를 모두 측정했고, 자기 자신의 넓이를 결정했기 때문에 (높이는 maxHeight 로 설정한다) 자식 뷰를 배치하는 일만 남았다. 자식 뷰는 x,y 좌표를 이용해 배치할 수 있는데, (0,0) 좌표가 해당 레이아웃의 좌측상단 위치가 되고, 우측하단으로 갈 수록 x,y 의 좌표가 증가한다.
우리는 Column 을 구현하고 있다. Column 은 자식 뷰들을 수직으로 나열하기 때문에, y 좌표를 증가시켜가며 자식들을 배치해야 한다.
var y = 0
layout(width = width, constraints.maxHeight) {
for (placeable in placeables) {
placeable.placeRelative(0, y)
y += placeable.height
}
}
y 변수는 자식 뷰를 배치할 y 좌표의 값을 저장한다. 여기서 layout 함수를 호출해서 자식 뷰들을 배치할 수 있는데, 만약 layout을 호출하지 않는다면 자식뷰들은 보이지 않는다. layout 함수는 width 와 height 를 매개변수로 받는다. 여기서 width 와 height 는 자식들을 담을 부모, 즉 자기 자신의 넓이와 높이를 의미한다. 이전에 width 변수에 최대 넓이를 저장했으므로 넓이를 width 로 설정해주자.
그 다음 Placeable 객체를 위치시켜야 한다. placeRelative 함수로 부모 뷰안에서 상대적인 좌표값을 가지고 자식들을 배치할 수 있다.
여기서 y 좌표값을 계속해서 더해주는 부분을 주목하자. 이번에 배치할 자식뷰의 높이를 더해줌으로써, 다음 아이템은 이전 자식뷰 바로 아래에 배치한다.
위와 같은 과정
1. 자식 뷰(Composable) 들의 크기를 측정한다
2. 자식 뷰들을 측정한 값을 기반으로 자기 자신의 넓이와 높이를 결정한다
3. 자식들을 배치한다.
을 거쳐서 아이템을 수직으로 배치하는 Column 을 직접 구현해 보았다. 이제 위 지식을 기반으로 Staggered Grid Layout 을 직접 구현해보자.
Staggered Grid Layout
우선 이 글에서는 가로로 배치되는 Grid Layout 을 구현할 것이다. 위 지식을 기반으로 머리만 잘 굴리면 사실 이 다음 내용을 보지 않고도 구현할 수 있을 것이다. 우선 우리가 구현하고자 하는 레이아웃은 다음과 같다.
우선 재활용성을 높이기 위해, 한 줄에 몇개의 자식을 보여줄지 row 의 개수를 매개변수로 받도록 설정해주자.
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3,
content: @Composable () -> Unit
)
기본값은 3으로 지정해주었다.
그 다음 1번 과정인 자식 뷰들을 측정하는 과정을 거치도록 하자
1. 자식 뷰의 크기를 측정하기!
Layout(content = content, modifier = modifier) { m, c ->
val rowWidths = IntArray(rows) { 0 }
val rowHeights = IntArray(rows) { 0 }
val placeables = m.mapIndexed { index, measurable ->
val placeable = measurable.measure(c)
val row = index % rows
rowWidths[row] += placeable.width
rowHeights[row] = Math.max(rowHeights[row], placeable.height)
placeable
}
여기서 우리는 엇갈린 격자 형태로 아이템을 배치해야 하기 때문에, 각 row 별로 아이템을 배치할 수 있도록 row 별 배열을 통해 자식 뷰들의 총 넓이와 높이를 관리하도록 하자. 이 때 각 row의 넓이는 자식 뷰들의 크기를 계속해서 더해야하고, height는 최대값을 가지는 자식 뷰의 높이로 지정해주면 된다.
2. 자기 자신의 넓이, 높이 지정하기
이렇게 각 row 별 넓이와 높이를 결정할 수 있다. 이제, 자기 자신의 넓이를 결정할 차례다. 자기 자신의 넓이는 row 중 가장 넓은 값으로 지정해주면 되고, height 는 각 row 의 높이를 모두 더해주면 된다.
val width = rowWidths.maxOrNull()?.coerceIn(c.minWidth.rangeTo(c.maxWidth)) ?: c.minWidth
val height = rowHeights.sumOf { it }.coerceIn(c.minHeight.rangeTo(c.maxHeight))
자식 뷰 배치하기
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i - 1] + rowHeights[i - 1]
}
위 코드를 통해 row 별 배치할 y 좌표값을 미리 지정해두도록 하자.
그 다음 Column 에서와 같이 layout 함수를 통해 자식 뷰들을 배치하면 된다.
layout(width = width, height = height) {
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
}
}
rowX 배열을 선언해 각 row 별로 다음 아이템을 배치할 x 좌표를 관리한다. 그 다음 아이템을 배치하며 각 row 별 width 를 더해가면서 아이템을 배치하면 Staggered Grid Layout 이 완성된다.
전체 코드
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { m, c ->
val rowWidths = IntArray(rows) { 0 }
val rowHeights = IntArray(rows) { 0 }
val placeables = m.mapIndexed { index, measurable ->
val placeable = measurable.measure(c)
val row = index % rows
rowWidths[row] += placeable.width
rowHeights[row] = Math.max(rowHeights[row], placeable.height)
placeable
}
val width = rowWidths.maxOrNull()?.coerceIn(c.minWidth.rangeTo(c.maxWidth)) ?: c.minWidth
val height = rowHeights.sumOf { it }.coerceIn(c.minHeight.rangeTo(c.maxHeight))
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i - 1] + rowHeights[i - 1]
}
layout(width = width, height = height) {
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
}
}
}
}
'안드로이드' 카테고리의 다른 글
Retrofit Interceptor 로 response 타입을 원하는 대로 바꿔보자 (0) | 2022.02.04 |
---|---|
Jetpack Compose State (0) | 2022.01.25 |
Comment