Jetpack Compose Layout ์ ์šฉ๊ธฐ: ์œ ์—ฐํ•˜๊ณ  ์„ฑ๋Šฅ์ด ๊ฐœ์„ ๋œ ํ™”๋ฉด์„ ๊ตฌํ˜„ํ•˜๊ธฐ๊นŒ์ง€

sana
  • #Android
  • #Jetpack Compose
  • #Compose
  • #Custom Layout

์•ˆ๋…•ํ•˜์„ธ์š”. ๋ชจ๋ฐ”์ผํŒ€ ์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœ์ž ์˜ค์ƒ์•„์ž…๋‹ˆ๋‹ค. ์ด๋ฒˆ ๊ธ€์—์„œ๋Š” Jetpack Compose ๋กœ ๊ตฌํ˜„๋œ ๊ด€์‹ฌ์•„ํ‹ฐ ์„ ํƒํ™”๋ฉด์—์„œ ์„ฑ๋Šฅ ๋ฐ ๋””์ž์ธ ์ด์Šˆ๋ฅผ ๋ฐœ๊ฒฌํ•˜๊ณ  ์ด๋ฅผ ํ•ด๊ฒฐํ•ด ๋‚˜๊ฐ„ ๊ณผ์ •์„ ๊ณต์œ ํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค.

๋””์ž์ธ ์š”๊ตฌ์‚ฌํ•ญ

์šฐ์„  ๊ด€์‹ฌ์•„ํ‹ฐ ์„ ํƒํ™”๋ฉด์˜ ๋””์ž์ธ์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

ํ•ด๋‹น ํ™”๋ฉด์€ ์ƒ๋‹จ์˜ ๋„ค๋น„๊ฒŒ์ด์…˜์„ ๋‹ด๋‹นํ•˜๋Š” TopBar๋ฅผ ์ œ์™ธํ•˜๋ฉด ํฌ๊ฒŒ ์„ธ ๋ถ€๋ถ„์œผ๋กœ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  1. ํ™”๋ฉด์— ๋Œ€ํ•ด ์•ˆ๋‚ดํ•˜๋Š” TextHeader (์ดํ•˜ TextHeader)
  2. ์•„ํ‹ฐ์ŠคํŠธ๋ช…์„ ๊ฒ€์ƒ‰ํ•˜๋Š” TextField (์ดํ•˜ SearchTextField)
  3. ์•„ํ‹ฐ์ŠคํŠธ์˜ ์‚ฌ์ง„๊ณผ ์ด๋ฆ„์„ ๊ทธ๋ฆฌ๋“œ ํ˜•ํƒœ๋กœ ๋ณด์—ฌ์ฃผ๋Š” ๋ฆฌ์ŠคํŠธ (์ดํ•˜ FavoriteList)

์ด ๋•Œ, SearchTextField๋Š” stickyHeader์˜ ์—ญํ• ์„ ํ•˜์—ฌ FavoriteList๊ฐ€ ์Šคํฌ๋กค๋˜์–ด๋„ ์ƒ๋‹จ์— ๊ณ ์ •๋˜์–ด์•ผ ํ•œ๋‹ค๋Š” ์š”๊ตฌ์‚ฌํ•ญ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

LazyColumn๊ณผ FlowRow ํ™œ์šฉํ•œ ์ฒซ๋ฒˆ์งธ ์‹œ๋„

์ฒ˜์Œ์—๋Š” ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด LazyColumn๊ณผ FlowRow๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. LazyColumn์˜ stickyHeader ํ•จ์ˆ˜๋ฅผ ํ™œ์šฉํ•˜์—ฌ SearchTextField๊ฐ€ ๊ณ ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•œ๋‹ค๋Š” ์š”๊ตฌ์‚ฌํ•ญ์„ ์†์‰ฝ๊ฒŒ ๋งŒ์กฑ์‹œํ‚ฌ ์ˆ˜ ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ LazyColumn์€ ์„ธ๋กœ ๋ฐฉํ–ฅ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์„ธ๋กœ๋กœ ๋ฐฐ์น˜ํ•  ์ˆ˜๋Š” ์žˆ์—ˆ์ง€๋งŒ, FavoriteList์˜ ์•„์ดํ…œ๋“ค์€ ๊ฒฉ์ž ํ˜•ํƒœ๋กœ ๋ฐฐ์น˜ํ•ด์•ผ ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— LazyColumn ์•ˆ์— FlowRow๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. FlowRow๋Š” ์•„์ดํ…œ์„ ๊ฐ€๋กœ๋กœ ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ๋Š” ๋ ˆ์ด์•„์›ƒ์œผ๋กœ, ์•„์ดํ…œ์˜ ํฌ๊ธฐ๋‚˜ ๊ฐœ์ˆ˜๊ฐ€ ๊ฐ€๋ณ€์ ์ผ ๋•Œ ์ด๋ฅผ ์ž๋™์œผ๋กœ ์กฐ์ •ํ•˜์—ฌ ํ™”๋ฉด ํฌ๊ธฐ์— ๋งž๊ฒŒ ๋™์ ์œผ๋กœ ๋ฐฐ์น˜ํ•˜๋Š” ๋ฐ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ๋‹ค์Œ ์ฝ”๋“œ์™€ ๊ฐ™์ด FlowRow๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํ™”๋ฉด์˜ ๋„ˆ๋น„์— ๋”ฐ๋ผ ํ•œ ์ค„(row)์— ๋ณด์—ฌ์ค„ ์•„์ดํ…œ์˜ ๊ฐœ์ˆ˜(itemInRow)๋ฅผ ๊ฒฐ์ •ํ•˜๊ณ , ์•„์ดํ…œ ์‚ฌ์ด์˜ ๊ฐ„๊ฒฉ(space)์„ ๊ณ„์‚ฐํ•˜์—ฌ ๋‹ค์–‘ํ•œ ๋””๋ฐ”์ด์Šค ํ™”๋ฉด์— ๋™์ ์œผ๋กœ ์•„์ดํ…œ์„ ๋ฐฐ์น˜ํ•˜์—ฌ ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค.


@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun FavoriteContent() {
    val configuration = LocalConfiguration.current
    val screenWidth = configuration.screenWidthDp
    val itemInRow = if (screenWidth > 490) 4 else if (screenWidth < 360) 2 else 3
    val space = (screenWidth - 48 - (itemInRow * 98)) / (itemInRow - 1)

    LazyColumn() {
        // 1. TextHeader
        item {
            TextHeader()
        }
        // 2. SearchTextField
        stickyHeader {
            SearchTextField()
        }
        // 3. FavoriteList
        item {
            FlowRow(
                maxItemsInEachRow = itemInRow,
                horizontalArrangement = Arrangement.spacedBy(space.dp),
            ) {
               
            }
        }
    }
}

ํ•˜์ง€๋งŒ stickyHeader ํ•จ์ˆ˜์˜ ๊ฒฝ์šฐ @ExperimentalFoundationApi ๋กœ ํ–ฅํ›„ ๋ณ€๊ฒฝ๋˜๊ฑฐ๋‚˜ ์ œ๊ฑฐ๋  ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์—ˆ๊ณ , FlowRow๋„ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ @ExperimentalLayoutApi๋กœ ์‹คํ—˜์ ์ธ ๋‹จ๊ณ„๋ผ ์•„์ง ์•ˆ์ •์ ์ด์ง€ ์•Š๋‹ค๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ FlowRow ๋Š” ์ง€์—ฐ(Lazy) ๋ ˆ์ด์•„์›ƒ์ด ์•„๋‹ˆ๋‹ค ๋ณด๋‹ˆ ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•œ ์•„์ดํ…œ๋“ค๋งŒ์„ ํ™”๋ฉด์— ํ‘œ์‹œํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ํ•œ ๋ฒˆ์— ๋ชจ๋“  ๋ชฉ๋ก์„ ๋ Œ๋”๋งํ•˜๋Š” ๋น„ํšจ์œจ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ๋””๋ฐ”์ด์Šค์˜ ํฌ๊ธฐ์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ์•„์ดํ…œ์„ ๋ฐฐ์น˜ํ•˜๋‹ค ๋ณด๋‹ˆ UI๋ฅผ ๊ทธ๋ฆฌ๋Š” ๋ฐ ์‹œ๊ฐ„์ด ๋งŽ์ด ๊ฑธ๋ ธ์Šต๋‹ˆ๋‹ค.

LazyVerticalGrid๋ฅผ ํ™œ์šฉํ•œ ๊ฐœ์„ 

๊ทธ๋ž˜์„œ ์œ„์˜ ์„ฑ๋Šฅ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด LazyColumn๊ณผ FlowRow ๋Œ€์‹  LazyVerticalGrid๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค. LazyVerticalGrid๋Š” FlowRow ์™€ ๊ฐ™์ด ๊ทธ๋ฆฌ๋“œ ํ˜•ํƒœ๋กœ ์•„์ดํ…œ์„ ๋ฐฐ์น˜ํ•˜๋ฉด์„œ๋„ Lazy ํ‚ค์›Œ๋“œ์—์„œ ์•Œ ์ˆ˜ ์žˆ๋“ฏ์ด ํ™”๋ฉด์— ํ‘œ์‹œ๋˜๋Š” ํ•ญ๋ชฉ๋งŒ ๊ตฌ์„ฑํ•˜์—ฌ ๋” ํšจ์œจ์ ์œผ๋กœ ๋™์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ๋””๋ฐ”์ด์Šค์˜ ํฌ๊ธฐ์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ํฌ๊ธฐ๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ์•„์ดํ…œ์„ ๋ฐฐ์น˜ํ•˜์ง€ ์•Š์•„๋„ ๋˜๊ธฐ ๋•Œ๋ฌธ์— ๋กœ๋”ฉ์‹œ๊ฐ„์„ ๋‹จ์ถ•์‹œํ‚ฌ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

์‹ค์ œ๋กœ Macrobenchmark ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ์˜ ๊ฐœ์ˆ˜์— ๋”ฐ๋ผ ๋กœ๋“œ๋˜๊ธฐ๊นŒ์ง€ ๊ฑธ๋ฆฌ๋Š” ์‹œ๊ฐ„์„ ์ธก์ •ํ•œ ๊ฒฐ๊ณผ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. 10๋ฒˆ ์ธก์ •ํ•œ ๊ฒฐ๊ณผ์˜ ํ‰๊ท ๊ฐ’์„ ํ‘œ์‹œํ•˜์˜€์Šต๋‹ˆ๋‹ค.

300๊ฐœ500๊ฐœ1000๊ฐœ
FlowRow364ms450ms663ms
LazyVerticalGrid299ms301ms300ms

300์—ฌ ๊ฐœ์˜ ์•„์ดํ…œ์„ ๊ทธ๋ฆฌ๋Š” ๋ฐ ํ‰๊ท  364ms ์†Œ์š”๋˜๋˜ ์‹œ๊ฐ„์ด 299ms๋กœ ์•ฝ 18% ๋‹จ์ถ•๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ LazyVerticalGrid๋Š” ์•„์ดํ…œ์˜ ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ•ด๋„ ์ผ์ •ํ•œ ์‹œ๊ฐ„ ๋‚ด์— ํ™”๋ฉด์„ ๊ทธ๋ฆด ์ˆ˜ ์žˆ์—ˆ์ง€๋งŒ, FlowRow๋Š” ์•„์ดํ…œ์˜ ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ• ์ˆ˜๋ก ๊ทธ๋ฆฌ๋Š” ์‹œ๊ฐ„์ด ์ฆ๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„์ดํ…œ์˜ ๊ฐœ์ˆ˜๊ฐ€ 1000๊ฐœ์ผ ๋•Œ๋Š” ๊ฑฐ์˜ 2๋ฐฐ ์ด์ƒ ์ฐจ์ด๊ฐ€ ๋‚ฌ์Šต๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ LazyVerticalGrid๋Š” stickyHeader ๋ฉ”์†Œ๋“œ๋ฅผ ์ œ๊ณตํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— SearchTextField๊ฐ€ ์ƒ๋‹จ์— ๊ณ ์ •๋˜์–ด์•ผ ํ•˜๋Š” ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑ์‹œํ‚ค๊ธฐ ์œ„ํ•ด ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ฐพ์€ ๋ฐฉ๋ฒ•์ด Box๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ๊ฒน์ณ์„œ ํ‘œ์‹œํ•˜๊ณ , LazyVerticalGrid์˜ scrollState๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ TextHeader์™€ SearchTextField์˜ ์œ„์น˜๋ฅผ ์กฐ์ ˆํ•˜๋Š” ๊ฒƒ์ด์—ˆ์Šต๋‹ˆ๋‹ค. scrollState์˜ ์ •๋ณด๋ฅผ ์ด์šฉํ•˜์—ฌ offset์„ ๊ณ„์‚ฐํ•˜์—ฌ ์‚ฌ์šฉ์ž๊ฐ€ ํ™”๋ฉด์„ ์•„๋ž˜๋กœ ์Šคํฌ๋กคํ•  ๋•Œ๋Š” ํ—ค๋”๊ฐ€ ์Šคํฌ๋กค์— ๋”ฐ๋ผ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ์ด๋™ํ•˜๋‹ค๊ฐ€, TextHeader์˜ ๋†’์ด๋งŒํผ ์Šคํฌ๋กค๋˜๋ฉด TextHeader๋Š” ํ™”๋ฉด ์œ„์ชฝ์œผ๋กœ ์™„์ „ํžˆ ์ˆจ๊ฒจ์ง€๊ณ  SearchTextField๋งŒ ํ™”๋ฉด์— ๋‚จ๋„๋ก ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ SearchTextField๊ฐ€ stickyHeader ์—ญํ• ์„ ํ•˜๋ฉด์„œ ๋ฆฌ์ŠคํŠธ๊ฐ€ ์Šคํฌ๋กค๋˜์–ด๋„ ์ƒ๋‹จ์— ๊ณ ์ •๋  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฅผ ๊ตฌํ˜„ํ•œ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.


@Composable
private fun FavoriteContent() {
    val headerMaxHeight = 110.dp.dpToPx().roundToInt()

    val scrollState = rememberLazyGridState()

    val headerOffset: Int by remember {
        derivedStateOf {
            if (scrollState.firstVisibleItemIndex == 0) {
                -min(scrollState.firstVisibleItemScrollOffset, headerMaxHeight)
            } else {
                -headerMaxHeight
            }
        }
    }

    Box() {
        // 3. FavoriteList
        LazyVerticalGrid(
            contentPadding = PaddingValues(top = 212.dp, bottom = 40.dp),
            state = scrollState, 
            ...
        )

        FavoriteHeaderContent(headerOffset)
    }
}

@Composable
private fun FavoriteHeaderContent(headerOffset: Int) {
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = headerOffset) }
    ) {
        // 1. TextHeader
        TextHeader(modifier = Modifier.height(110.dp))

        // 2. SearchTextField
        SearchTextField(modifier = Modifier.height(40.dp))
    }
}

ํ•˜์ง€๋งŒ ์ด ๋ฐฉ๋ฒ•๋„ ๋ชจ๋“  ์š”์†Œ์˜ ๋†’์ด๊ฐ€ ์ƒ์ˆ˜๋กœ ๊ณ ์ •๋˜์–ด ์žˆ์–ด์„œ ์ข‹์€ ํ•ด๊ฒฐ๋ฐฉ๋ฒ•์€ ์•„๋‹ˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์‹ค์ œ๋กœ ํ—ค๋”์˜ ๋†’์ด๊ฐ€ ๊ณ ์ •๋˜์–ด ์žˆ๋Š”๋ฐ ํ…์ŠคํŠธ์˜ ๊ธธ์ด๊ฐ€ ๊ธธ์–ด๊ฐ€ ์ค„๋ฐ”๊ฟˆ์„ ํ•˜๊ฒŒ ๋˜๋ฉด ๋‚ด์šฉ์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ณผ ์ˆ˜ ์—†๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ๋””์ž์ธ ์ž‘์—…์‹œ ํ•œ๊ธ€์„ ๊ธฐ์ค€์œผ๋กœ ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ํ•œ๊ธ€์—์„œ๋Š” ๋ฌธ์ œ๊ฐ€ ์—†์—ˆ์ง€๋งŒ, ์ผ๋ณธ์–ด๋กœ ์–ธ์–ด๋ฅผ ๋ฐ”๊พธ๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ…์ŠคํŠธ๊ฐ€ ์ž˜๋ ค๋ณด์˜€์Šต๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ํ—ค๋”์˜ ๋†’์ด๋ฅผ ์œ ๋™์ ์œผ๋กœ ์กฐ์ ˆํ•˜์—ฌ ํ…์ŠคํŠธ ๊ธธ์ด์— ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์ฐพ์•„์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค.

Layout ์ปดํฌ์ €๋ธ”์„ ํ™œ์šฉํ•œ ์ตœ์ข… ๊ฐœ์„ 

์šฐ์„  ์Šคํฌ๋กค ์œ„์น˜๋ฅผ ์ด์šฉํ•˜์—ฌ FavoriteHeaderContent์™€ LazyVerticalGrid์˜ ์œ„์น˜๋ฅผ ์กฐ์ ˆํ•œ๋‹ค๋Š” ์ฃผ์š” ์•„์ด๋””์–ด๋Š” ๊ฐ™์•˜์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TextHeader์˜ ๋†’์ด๋ฅผ 110dp๋กœ ๊ณ ์ •ํ•˜๋Š” ๋Œ€์‹ ์— wrapContentHeight๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ํ…์ŠคํŠธ์˜ ๊ธธ์ด์— ๋”ฐ๋ผ ๋†’์ด๊ฐ€ ์ •ํ•ด์ง€๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  Box๋Œ€์‹ ์— Layout ์ปดํฌ์ €๋ธ”์„ ์‚ฌ์šฉํ•˜์—ฌ ์ž์‹ ์š”์†Œ๋“ค์„ ๋ฐฐ์น˜ํ•˜๊ณ , ํฌ๊ธฐ๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ๋™์ ์œผ๋กœ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

๋ณ€๊ฒฝ๋œ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.


@Composable
private fun FavoriteContent() {
    val scrollState = rememberLazyGridState()

    FavoriteContentLayout(
        firstVisibleItemIndex = {
            scrollState.firstVisibleItemIndex
        },
        firstVisibleItemScrollOffset = {
            scrollState.firstVisibleItemScrollOffset
        },
    ) {
        // 3. FavoriteList
        LazyVerticalGrid(
            contentPadding = PaddingValues(top = HeaderGradientHeight, bottom = HeaderFixedHeight + 40.dp),
            state = scrollState, 
            ...
        )
        Spacer(modifier = Modifier.fillMaxWidth().height(HeaderGradientHeight))
        FavoriteHeaderContent()
    }
}

@Composable
private fun FavoriteHeaderContent(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier
    ) {
        // 1. TextHeader
        TextHeader(modifier = Modifier.wrapContentHeight())
      
        // 2. SearchTextField
        SearchTextField()
    }
}

์—ฌ๊ธฐ์„œ FavoriteContentLayout์€ Layout ์ปดํฌ์ €๋ธ”์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„๋˜์—ˆ๋Š”๋ฐ, Layout ์ปดํฌ์ €๋ธ”์€ Compose UI์˜ ๊ธฐ๋ณธ ์š”์†Œ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ Compose์—์„œ ์ œ๊ณตํ•˜๋Š” Column, Row, Box ๋“ฑ ๊ธฐ๋ณธ์ ์ธ ๋ ˆ์ด์•„์›ƒ์ด ๋ชจ๋‘ ์ด Layout ์ปดํฌ์ €๋ธ”์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. Layout์„ ํ™œ์šฉํ•˜๋ฉด ๊ธฐ๋ณธ์ ์ธ ๋ ˆ์ด์•„์›ƒ ์ด์™ธ์—๋„ ์›ํ•˜๋Š” ํ˜•ํƒœ๋กœ ํ™”๋ฉด์„ ์ข€ ๋” ์ปค์Šคํ…€ํ•˜๊ฒŒ ์ œ์–ดํ•˜๊ณ  ๋‹ค์–‘ํ•œ ๋””์ž์ธ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด ์‚ฌ์šฉํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ Layout์€ ์–ด๋–ป๊ฒŒ ์ปค์Šคํ…€ ๋ ˆ์ด์•„์›ƒ ๊ตฌํ˜„์„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ผ๊นŒ์š”?

์ด๋ฅผ ์ดํ•ดํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Compose์—์„œ UI๋ฅผ ๊ทธ๋ฆฌ๋Š” ๊ณผ์ •์„ ์•Œ์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Compose๋Š” ๊ตฌ์„ฑ(Composition), ๋ ˆ์ด์•„์›ƒ(Layout), ๊ทธ๋ฆฌ๊ธฐ(Drawing)์˜ ์„ธ ๋‹จ๊ณ„๋ฅผ ๊ฑฐ์ณ ์ƒํƒœ๋ฅผ UI๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, Layout ๋‹จ๊ณ„์—์„œ๋Š” ๊ตฌ์„ฑ ๋‹จ๊ณ„์—์„œ ์ƒ์„ฑํ•œ UI ํŠธ๋ฆฌ๋ฅผ ์ˆœํšŒํ•˜๋ฉฐ ์ž์‹์„ ์ธก์ •(measure)ํ•˜๊ณ , ์ž์‹ ์˜ ํฌ๊ธฐ(size)๋ฅผ ๊ฒฐ์ •ํ•œ ๋‹ค์Œ ์ž์‹์„ ๋ฐฐ์น˜(place)ํ•ฉ๋‹ˆ๋‹ค. Layout ์ปดํฌ์ €๋ธ”์„ ์‚ฌ์šฉํ•˜๋ฉด ์ด ๋‹จ๊ณ„๋ฅผ ์ง์ ‘ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋” ์œ ์—ฐํ•˜๊ณ  ์„ธ๋ฐ€ํ•œ ๋ ˆ์ด์•„์›ƒ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ๋‹ค์‹œ ์ฝ”๋“œ๋กœ ๋Œ์•„๊ฐ€ FavoriteContentLayout ๋‚ด๋ถ€ ๊ตฌํ˜„์„ ํ†ตํ•ด ์ด ๋‹จ๊ณ„๋ฅผ ์ž์„ธํžˆ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ์ฃผ๋ชฉํ•ด์„œ ๋ณผ ๋ถ€๋ถ„์€ Layout์˜ ๋งˆ์ง€๋ง‰ ํŒŒ๋ผ๋ฏธํ„ฐ์ธ measurePolicy ๋žŒ๋‹ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ๋ ˆ์ด์•„์›ƒ์˜ ์ธก์ •๊ณผ ๋ฐฐ์น˜๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ๋กœ์ง์„ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

@Composable
private fun FavoriteContentLayout(
    firstVisibleItemScrollOffset: () -> Int,
    firstVisibleItemIndex: () -> Int,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val fixedHeight = HeaderFixedHeight.dpToPx().roundToInt()

    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val gridPlaceable = measurables.first { it.layoutId == GridLayoutID }.measure(constraints)
        val spacerPlaceable = measurables.first { it.layoutId == SpacerLayoutID }.measure(constraints)
        val headerPlaceable = measurables.first { it.layoutId == HeaderLayoutID }.measure(constraints)

        val scrollIndex = firstVisibleItemIndex()
        val scrollOffset = firstVisibleItemScrollOffset()
        val dynamicHeight = headerPlaceable.height - fixedHeight
        val offset = if (scrollIndex == 0) {
            -min(scrollOffset, dynamicHeight)
        } else {
            -dynamicHeight
        }

        layout(
            width = constraints.maxWidth,
            height = constraints.maxHeight,
        ) {
            gridPlaceable.placeRelative(10, headerPlaceable.height + offset)
            headerPlaceable.placeRelative(0, offset)
            spacerPlaceable.placeRelative(0, headerPlaceable.height + offset)
        }
    }
}

๋žŒ๋‹ค์—์„œ ๋„˜์–ด์˜ค๋Š” measurables ๋Š” ๋ ˆ์ด์•„์›ƒ์— ํฌํ•จ๋œ ์ž์‹ ์š”์†Œ๋“ค์„ ๋‚˜ํƒ€๋‚ด๋Š”๋ฐ, layoutId๋ฅผ ํ†ตํ•ด ๊ฐ ์š”์†Œ๋ฅผ ์‹๋ณ„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ ๋ ˆ์ด์•„์›ƒ์˜ ๋„ˆ๋น„์™€ ๋†’์ด ๋“ฑ์„ ์ œํ•œํ•˜๋Š” ์ œ์•ฝ ์กฐ๊ฑด์ธ constraints ์™€ ํ•จ๊ป˜ measure()๋ฅผ ํ†ตํ•ด ๊ฐ ์š”์†Œ๋ฅผ ์ธก์ •ํ•˜๊ณ  ํ•ด๋‹น ํฌ๊ธฐ๋ฅผ Placeable ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. Placeable์€ ์ธก์ •์ด ๋œ ์ž์‹์œผ๋กœ ํฌ๊ธฐ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, placeRelative()๋ฅผ ํ†ตํ•ด ์›ํ•˜๋Š” ์œ„์น˜์— ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ๋Š” measure()๋ฅผ ํ•œ ๋‹ค์Œ headerPlaceable.height๋ฅผ ํ†ตํ•ด ํ—ค๋”์˜ ๋†’์ด๋ฅผ ์–ป๊ณ , ํ˜„์žฌ ์Šคํฌ๋กค ์œ„์น˜์ธ scrollOffset๊ณผ ์ฒซ ๋ฒˆ์งธ ๋ณด์ด๋Š” ์•„์ดํ…œ์˜ ์ธ๋ฑ์Šค์ธ scrollIndex๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ offset ๋ณ€์ˆ˜๋ฅผ ๊ณ„์‚ฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  layout() ํ•จ์ˆ˜์—์„œ offset์„ ์ด์šฉํ•ด ๊ฐ ์š”์†Œ๋“ค์ด ์Šคํฌ๋กค ์œ„์น˜์— ๋”ฐ๋ผ ์ ์ ˆํ•˜๊ฒŒ ๋ฐฐ์น˜๋˜๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ Layout์„ ์‚ฌ์šฉํ•จ์œผ๋กœ์จ ์Šคํฌ๋กค๊ณผ ๊ฐ™์€ ์‚ฌ์šฉ์ž ์ƒํ˜ธ ์ž‘์šฉ์— ๋”ฐ๋ผ ์š”์†Œ๋“ค์„ ์ด๋™์‹œ์ผœ ๋””์ž์ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

๋งˆ๋ฌด๋ฆฌ

์ด๋ฒˆ ๊ธ€์—์„œ๋Š” Jetpack Compose๋ฅผ ์‚ฌ์šฉํ•œ ๊ด€์‹ฌ ์•„ํ‹ฐ์ŠคํŠธ ์„ ํƒ ํ™”๋ฉด ๊ตฌํ˜„ ๊ณผ์ •์—์„œ ๋ฐœ์ƒํ•œ ์„ฑ๋Šฅ ๋ฐ ๋””์ž์ธ ์ด์Šˆ๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ๊ณต์œ ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ดˆ๊ธฐ์—๋Š” LazyColumn๊ณผ FlowRow๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ถฉ์กฑ์‹œ์ผฐ์œผ๋‚˜, ExperimentalApi์˜ ๋ถˆ์•ˆ์ •์„ฑ๊ณผ ์„ฑ๋Šฅ ์ €ํ•˜ ๋ฌธ์ œ๋กœ ์ธํ•ด LazyVerticalGrid๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ ๊ณผ์ •์—์„œ stickyHeader ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด wrapContentHeight() ๋ฅผ ์ ์šฉํ•˜๊ณ , Layout ์ปดํฌ์ €๋ธ”์„ ์‚ฌ์šฉํ•˜์—ฌ ๋™์ ์ธ ๋ ˆ์ด์•„์›ƒ ์กฐ์ •์„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ ๋‹ค์–‘ํ•œ ๋””๋ฐ”์ด์Šค ํฌ๊ธฐ์™€ ์–ธ์–ด ์„ค์ •์—์„œ๋„ ์ผ๊ด€๋œ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๊ณ , ์„ฑ๋Šฅ๋„ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

์•ž์œผ๋กœ๋„ ๊พธ์ค€ํžˆ Compose์— ๋Œ€ํ•ด ํ•™์Šตํ•˜๋ฉด์„œ ๋” ๋‚˜์€ UI/UX๋ฅผ ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•ด ๋…ธ๋ ฅํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค. ์ฝ์–ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.

์ฐธ๊ณ 

Deep dive into Jetpack Compose layouts

โ† ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ

Art Changes Life

๋…ธ๋จธ์Šค์™€ ํ•จ๊ป˜ ์—”ํ„ฐํ…Œํฌ ์‚ฐ์—…์„ ํ˜์‹ ํ•ด๋‚˜๊ฐˆ ๋ฉค๋ฒ„๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค.

์ฑ„์šฉ ์ค‘์ธ ๊ณต๊ณ  ๋ณด๊ธฐ