읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. Compose에서 목록형 레이아웃에 아이템들을 담아 출력하는 LazyList 관련된 컴포저블에 대해 실습합니다. 이미지를 네트워크에 요청하여 렌더링해본 뒤 코루틴을 사용하여 스크롤 제어까지 다룹니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

목록 사용

아이템 목록 출력은 앱 구성 시 많은 곳에서 사용된다. 아이템을 Row로 구성하고 Column에 쌓아서 리스팅을 구현할 수 있을 것이라 간단하게 예상할 수 있다. Jetpack Compose에서는 리스트 출력에 최적화된 Lazy List를 제공한다.

Column 컴포저블을 사용하여 100개의 목록을 출력하는 컴포저블 함수는 다음과 같이 작성할 수 있다.

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

다만, Column 컴포저블은 기본적으로 스크롤 속성이 부여되지 않았기에 화면 밖 아이템을 표시할 수 없다. 스크롤이 가능하도록 verticalScroll Modifier를 추가하자.

@Composable
fun SimpleList() {
    // 해당 state로 스크롤 위치를 저장할 수 있으며 코드로 리스트를 스크롤할 수 있게끔 해준다.
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Lazy List

앞서 작성했듯이 Column 컴포저블을 사용하여 리스트를 출력하면 화면에 표시되지 않는 아이템들도 모두 한번에 렌더링하여 성능 이슈가 발생한다. 뷰에서의 RecyclerView처럼 화면에 등장할 때만 재사용하여 렌더링하는 컴포저블에서는 LazyColumn에 해당한다. LazyColumn은 Column과는 달리 scroll Modifier없이 즉시 사용할 수 있다.

@Composable
fun LazyList() {

    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

Codelab_Jetpack_Compose_layout_019

이미지 출력

원격으로 이미지를 받아서 리스트에 출력해보자. Image 컴포저블을 사용하연 비트맵이나 벡터 이미지를 출력할 수 있다. Coil 라이브러리를 사용하여 Image에 이미지 리소스를 입력하게끔 활용하였다. Coil 라이브러리 사용을 위해 사전 작업이 필요하다.

Build.gradle에 라이브러리를 import 한다.

// build.gradle
implementation 'io.coil-kt:coil-compose:1.4.0'

네트워크를 이용하여 이미지 리소스를 요청할 예정으로 AndroidManifest에 권한을 추가한다.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

Row 컴포저블을 사용하여 출력할 아이템을 구성한다. 차례로 이미지, 구분 간격, 텍스트를 정의하였다.

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

LazyColumn 컴포저블의 하위 컴포저블로 ImageListItem 컴포저블을 입력한다.

@Composable
fun ImageList() {

    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

Codelab_Jetpack_Compose_layout_020

스크롤 제어

앞서 state를 사용하여 스크롤 위치를 기억하게끔 만들었다. 이걸 직접 코드를 작성하여 수동으로 제어할 수도 있다. 목록 상단과 하단으로 스크롤할 수 있도록 버튼 두 개를 작성한다. 스크롤이 수행되는 동안 중간의 리스트 렌더링 방지를 위해 스크롤 API는 suspend 함수로 구현되어 있다. 따라서, suspend 함수 호출을 위해 코루틴을 생성해야 한다. 컴포즈에서 사용할 수 있도록 rememberCoroutineScope 함수를 사용하여 CorotineScope를 생성한 뒤 해당 scope에서 동작을 수행하면 되겠다.

@Composable
fun ImageList() {
    val listSize = 100 // 지정된 위치로 스크롤할 수 있도록 전체 아이템 개수 fix
    // 스크롤 위치를 기억하는 state
    val scrollState = rememberLazyListState()
    // 스크롤링 액션이 실행될 수 있도록 coroutine scope 저장
    val coroutineScope = rememberCoroutineScope()
    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0번째 원소가 첫번째 아이템
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }
            Button(onClick = {
                coroutineScope.launch {
                    // 0좌표 기준에 따라 listSize - 1는 마지막
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the bottom")
            }
        }
        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

Codelab_Jetpack_Compose_layout_021

Floating Button 사용

앞서 작성한 예제처럼 버튼을 고정적으로 작성하여 스크롤 제어를 구현하는 경우는 많지 않다. 대부분 Floating Button을 작성하여 클릭 시 top position으로 이동하게끔 작성한다. Scaffold를 사용하여 Floating Button을 작성한 뒤 스크롤 이벤트를 정의하였다.

@Composable
fun ImageList() {
    val listSize = 100
    // 스크롤 위치를 기억하는 state
    val scrollState = rememberLazyListState()
    // 스크롤링 액션이 실행될 수 있도록 coroutine scope 저장
    val coroutineScope = rememberCoroutineScope()
    Scaffold(
        floatingActionButton = {
            ExtendedFloatingActionButton(
                icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, 
                text = { Text("TOP") }, 
                onClick = { 
                    coroutineScope.launch { 
                        // 0좌표는 첫번째 아이템 좌표
                        scrollState.animateScrollToItem(0) 
                    } 
                }
            )
        }
    ) {
        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

Codelab_Jetpack_Compose_layout_022

좀 더 고도화를 한다면 top position이 화면에 렌더링된 경우는 Floating Button을 숨겼다가 scroll이 하단으로 이동하여 top position이 화면에서 사라진 경우 Floating Button을 등장시키는 방법도 있겠다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #6 목록 사용

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 앱의 디자인을 위해 Material Design Component 도입을 위한 Scaffold 레이아웃과 기본적인 사용법에 대해 다룹니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

Material Component(머티리얼 구성요소)

앱을 구현하면서 디자인 측면을 넘어서 UI 컴포넌트까지 Material 라이브러리는 광범위하게 영향을 끼친다. 컴포즈에서는 특히 더욱 그러한데 그 중에서도 Scaffold는 가장 높은 수준의 컴포저블이다.

Scaffold

Scaffold를 사용하면 기본 머티리얼 디자인 레이아웃 구조로 UI를 구현할 수 있으며 페이지 구성 시 자주 쓰이는 TopAppBar, BottomAppBar, FloatingActionButton 등 최상위 머티리얼 컴포넌트를 위한 슬롯(공간)을 제공한다.

Scaffold 본문에 Text 컴포저블을 배치해보자.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

Scaffold 컴포저블의 파라미터는 @Composable (InnerPadding) -> Unit의 본문 content를 제외하고 선택적으로 부여할 수 있다. 하위 람다 함수를 사용하여 본문 컴포저블을 정의할 수 있으며 매개변수로 패딩을 받는다. 해당 패딩은 화면의 항목을 적절히 제한하도록 컨텐츠 루트 컴포저블에 적용한다고 한다.

화면의 기본 컨텐츠를 Vertical하게 배치한다고 가정하고 Column 컴포저블 내부에 쌓아보자.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

가급적이면 코드 재사용과 테스팅 편의를 위해 작은 단위로 코드를 구조화하면 좋다. 본문에 들어갈 컴포저블을 따로 컴포저블 함수로 구성하자.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

TopAppBar

많은 앱은 상단 AppBar를 갖는다. Scaffold도 이를 위해 @Composable () -> Unit 유형의 topBar라는 파라미터를 사용하여 상단 AppBar 슬롯에 컴포저블을 배치할 수 있다. h3 스타일의 텍스트를 상단 앱바로 사용하려면 다음과 같이 작성하면 된다.

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Codelab_Jetpack_Compose_layout_016

컴포저블 함수 작성 결과는 위 그림과 같다.

단순 텍스트로 상단 앱 바를 구성하기보다 title, 탐색 아이콘, 작업용 슬롯, navigation icon이 있는 TopAppBar 컴포저블을 사용할 수 있다. 기존 텍스트로만 구성한 상단 앱 바를 TopAppBar로 교체하고 title 슬롯에 Text 컴포저블을 정의해보자.

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Codelab_Jetpack_Compose_layout_017

프로젝트에 적용된 테마에 맞춰서 상단 앱 바가 구성되었음을 확인할 수 있다. TopAppBar를 사용했으니 타이틀 말고도 다른 파라미터를 사용할 수 있다. 상단 앱 바에서 자주 보던 좌측 navigationIcon이나 우측 작업 버튼 등을 정의할 수 있다. 우측 작업 버튼을 배치해보자.

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Codelab_Jetpack_Compose_layout_018

Preview로 컴포저블 함수를 출력한 결과 작업버튼이 잘 배치되었음을 확인할 수 있다.

추가 Modifier 배치

Modifier는 컴포저블의 padding, background 등 기본적인 모양새를 결정한다. 따라서, 새로운 컴포저블 생성 시 기본값이 Modifier로 지정된 modifier 매개변수를 정의해두면 컴포저블 함수의 재사용성이 좋아진다. 앞서 작성했던 BodyContent 컴포저블 함수에 파라미터로 Modifier를 정의하여 하위 요소들을 감싸는 Column 컴포저블의 modifier 파라미터로 입력했었다. 패딩을 Column 컴포저블에 더 추가하고 싶다면 두 가지로 정리할 수 있다.

  1. BodyContent 컴포저블 함수가 호출될 때마다 추가 패딩이 적용되도록 modifier padding을 하위 요소에 적용한다.
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

Modifier가 컴포저블의 고유한 속성이라면 1번 방법처럼 함수 내부에 선언한다.

  1. 추가 padding이 필요할 때만 컴포저블 함수 선언 시 Modifier 적용
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

Modifier가 컴포저블의 고유한 속성이 아니라 주입해야 한다면 외부로부터 입력받도록 작성한다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #5 머티리얼 구성요소

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. Slot API를 사용하여 버튼을 커스텀하고 상단 앱 바를 정의해봅니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

Slot(슬롯) API

코드랩에서 해당 섹션은 이론적인 내용이라며 실습 코드를 조금 비틀어놓았다. 예시와 동작하도록 실제로 사용할 수 있는 코드로 재작성하였다.

구글이 설명하기를 Slot API는 구글이 개발자들의 UI 컴포넌트 커스텀 요구 속도를 따라잡을 수 없어 "슬롯"이라는 개념을 도입하여 내부에 개발자가 원하는 요소로 채울 수 있게끔 공간을 제공한다. 다음 그림의 빈 공간이 슬롯 API가 개발자로 하여금 작성할 수 있도록 비워둔 공간을 의미한다.

Slot API 예시 - 버튼

Codelab_Jetpack_Compose_layout_011

기존 뷰를 사용하여 기본 버튼을 생성한다면 다음 그림 모양의 버튼이 생성되었을 것이다.

Codelab_Jetpack_Compose_layout_012

위 그림대로 버튼을 컴포즈로 구현하면 다음과 같다.

@Composable
fun SlotButton() {
    Button(onClick = {}) {
        Text(text = "Button")
    }
}

이전까지 뷰 기반 UI 컴포넌트에서는 버튼을 작성하고 텍스트를 변경하는 정도가 대부분의 사용례였다. 컴포즈를 사용한다면 좀 더 동적으로 버튼을 가공할 수 있다.

Codelab_Jetpack_Compose_layout_013

위의 버튼을 컴포즈로 구현해보자.

@Composable
fun SlotButton(modifier: Modifier = Modifier) {
    Button(onClick = {} ) {
            Image(Icons.Filled.Favorite, contentDescription = null)
            Spacer(Modifier.width(4.dp))
            Text(text = "Button")
        }
}

코드랩의 이미지와는 달리 하트 아이콘의 색상이 다르게 표기되는 경우가 있지만 이는 프로젝트 색상 설정의 차이이므로 적당히 color를 부여하면 되겠다. 중요한 점은 개발자가 의도하는 대로 버튼에 컴포즈를 부여할 수 있음이다. 개발자가 버튼 컴포저블에 하위 컴포저블을 입력할 수 있도록 어떻게 구현했는지 버튼의 내부 동작 코드를 열어봤다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
    val contentColor by colors.contentColor(enabled)
    Surface(
        modifier = modifier,
        shape = shape,
        color = colors.backgroundColor(enabled).value,
        contentColor = contentColor.copy(alpha = 1f),
        border = border,
        elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
        onClick = onClick,
        enabled = enabled,
        role = Role.Button,
        interactionSource = interactionSource,
        indication = rememberRipple()
    ) {
        CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
            ProvideTextStyle(
                value = MaterialTheme.typography.button
            ) {
                Row(
                    Modifier
                        .defaultMinSize(
                            minWidth = ButtonDefaults.MinWidth,
                            minHeight = ButtonDefaults.MinHeight
                        )
                        .padding(contentPadding),
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                    content = content
                )
            }
        }
    }
}

버튼 컴포저블의 content 파라미터를 통해 컴포저블을 입력할 수 있도록 하위 컴포저블 람다를 사용한다. 이를 통해 버튼 내에서 출력되도록 자체 컴포저블들을 정의할 수 있다. content 파라미터는 내부 Row 컴포저블의 content로 사용된다. verticalAlignment에 이미 속성이 부여되어 있어 별도의 조치를 하지 않고도 작성된 하위 컴포저블들이 중앙 정렬된 채로 출력된다.

만약 하위 컴포저블을 Row 컴포저블로 감싸서 입력하면 어떨까.

@Composable
fun SlotButton(modifier: Modifier = Modifier) {
    Button(onClick = {} ) {
        Row {
            Image(Icons.Filled.Favorite, contentDescription = null)
            Spacer(Modifier.width(4.dp))
            Text(text = "Button")
        }
    }
}

위 코드대로 작성하면 Text 컴포저블이 상단에 붙은 채로 출력이 된다. 그 이유는 Button 컴포저블의 하위 컴포저블은 Row 컴포저블이고 해당 컴포저블에 대해 중앙정렬할 뿐, 나머지에 대해서는 중앙정렬하지 않기 때문이다. 따라서, Row로 감싸고도 중앙정렬 상태로르 유지하려면 하위 컴포저블에 대해서 중앙정렬 속성을 다음과 같이 부여해야 한다.

@Composable
fun SlotButton(modifier: Modifier = Modifier) {
    Button(onClick = {} ) {
        Row {
            Image(Icons.Filled.Favorite, contentDescription = null)
            Spacer(Modifier.width(4.dp))
            Text(text = "Button", Modifier.align(Alignment.CenterVertically))
        }
    }
}

그러나 속성을 한 번 더 작성해야 하는 수고로움을 따져봤을 때 이미 Button 컴포저블 내부에 Row 컴포저블이 선언되어있으므로 굳이 Row 컴포저블로 감쌀 필요는 없어 보인다.

Slot API 예시 - 상단 앱 바

슬롯 API을 사용하는 가장 대표적인 사용례로 상단 앱 바를 들 수 있다.

Codelab_Jetpack_Compose_layout_014

위 그림대로 상단 앱 바를 구성함이 대부분의 경우인데, 뷰 기반 UI 컴포넌트와 마찬가지로 각각 구분해서 다르게 정의할 수 있다.

Codelab_Jetpack_Compose_layout_015

@Composable
fun SlotTopAppBar() {
    TopAppBar(
        title = {Text(text = "Page title", maxLines = 2)},
        navigationIcon = {Icon(Icons.Filled.Menu, contentDescription = null)}
    )
}

예시로 든 상단 앱 바를 간단히 구현하면 위 코드처럼 작성할 수 있다. 그러나 title, navigationIcon에 국한되지 않고 하위 컴포저블을 구현하려 한다면 content를 위한 별도의 람다 컴포저블 함수를 정의하여 Box, Row 등을 사용하여 정의할 수 있겠다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #4 슬롯 API

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 기본적인 Modifier 적용과 순서에 따른 동작 차이점에 대해 다룹니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

프로젝트 시작

실습을 위해 새로운 안드로이드 프로젝트를 생성한 뒤 Empty Compose Activity를 선택하여 생성한다. Compose API의 최소 호환 버전을 맞추기 위해 minimumSdkVersion을 API Level 21 이상으로 설정해야 한다.

Codelab_Jetpack_Compose_layout_001

Codelab_Jetpack_Compose_layout_002

Modifier(수정자)

Codelab_Jetpack_Compose_layout_003

Modifier 섹션에서는 위의 그림대로 컴포저블을 구성해본다.

Modifier를 사용하여 컴포저블을 가공할 수 있다. 라벨 추가, 클릭 및 스크롤 부여, 동작 및 모양 등을 정의할 수 있다. 또한, Modifier는 코틀린 객체로 변수에 할당 및 재사용이 가능하며 여러 수정자를 체이닝하여 구성할 수 있으며 확장함수를 정의할 수도 있다.

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha는 자신의 scope 하단의 자식 컴포즈의 opacity level을 정의한다.
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

Column 컴포저블을 사용하여 수직 레이아웃을 선언한 뒤 상단에 Text 컴포저블을 선언하고 하단에 CompositionLocalProvider를 사용하여 내부 scope에 Text 컴포저블을 선언한 뒤 스타일을 부여하였다. Preview Annotation을 선언한 Preview용 컴포저블 함수를 선언하여 정의한 레이아웃이 어떤 모습을 갖는지 확인할 수 있다.

Codelab_Jetpack_Compose_layout_004

텍스트 구성은 마무리되었다. 이제 사진사 사진을 로드하기 전까지의 공간을 표시하기 위해 placeholder를 정의해야 한다.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

사진사의 사진과 텍스트는 서로 가로로 쌓이는 모양새이므로 바깥에 Row를 선언한 뒤 사진사 사진을 담을 Surface 컴포저블을 선언한다. 이후 두 텍스트 컴포저블이 담긴 Column 컴포저블이 선언되었다.

Codelab_Jetpack_Compose_layout_005

필자의 안드로이드 스튜디오의 모드가 다크모드라 좌측 placeholder의 색상이 가려졌다. 라이트모드에서 보면 확실히 회색 원형 컴포저블이 생성되었음을 확인할 수 있다.

Codelab_Jetpack_Compose_layout_006

섹션 처음에 생성하려는 컴포저블과 비교하여 두 가지 개선할 점이 보인다.

  1. placeholder와 텍스트 사이의 간격이 필요하다.

우측 두 개의 텍스트 컴포저블을 담은 Column 컴포저블과 placeholder 컴포저블 간 간격을 띄워야 하므로 Column 컴포저블의 파라미터에 Modifier를 부여할 수 있다. Modifier.padding(start = 8.dp)를 부여하자.

  1. 사진사의 설명이 적힌 텍스트 컴포저블이 세로로 가운데 위치해야 한다.

우측 Column 컴포저블에 속한 텍스트 컴포저블들이 수직 중앙 정렬되었으면 한다. 그렇다면 Column 컴포저블의 scope에 속한 컴포저블에 대해 정렬 속성을 부여하자. 역시나 Modifier 파라미터에 속성을 부여해야 한다. .align(Alignment.CenterVertically)를 부여하자.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

Codelab_Jetpack_Compose_layout_007

좌측 placeholder와 텍스트 간의 간격이 생겼고 텍스트 또한 수직 중앙정렬된 모습이다. 이렇듯 Modifier를 사용하여 컴포저블을 다양하게 조정할 수 있다. 따라서, 커스텀 컴포저블을 만들 때 파라미터로 Modifier를 받게끔 하여 외부에서 Modifier를 주입할 수 있도록 작성하자.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

주의할 점으로는 위 코드대로 파라미터에 Modifier를 작성하여 Row 컴포저블을 조정할 수 있는 옵션을 부여하였는데 내부에 선언할 컴포저블에 대해서도 스타일을 조정할 필요가 있는지 염두에 둬야 한다. 굳이 Modifier를 부여할 필요가 없다면 스타일 파라미터를 작성하여 개별적으로 부여할 수 있을 것이다.

@Composable
fun PhotographerCard(
    modifier: Modifier = Modifier,
    fontColor: Color = Color.Black,
    content: String = "") {
    Row(modifier) { 
        Text(text = content, color = fontColor)
    }
}

Modifier 순서의 중요성

위에서 작성한 Modifier.padding(start = 8.dp).align(Alignment.CenterVertically처럼 Modifier를 적용할 때는 확장함수들을 적용하여 체이닝한다. 단일 객체로 연결하여 사용하기에 순서에 따라 최종 결과가 달라진다. 사진가 프로필에 클릭 속성을 부여하면서 패딩도 적용해보자.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

전체 상하좌우 패딩을 16.dp씩 부여하고 클릭 속성을 부여하였다. 이에 대해 클릭을 수행하면 아래와 같이 동작한다.

Codelab_Jetpack_Compose_layout_008

그림을 보면 모든 영역에 클릭이 적용되지 않는다. 그 이유는 padding이 clickable보다 앞에 적용되었기 때문이다. 만약 순서를 바꿔서 padding을 clickable modifier 뒤에 적용하면 패딩 영역도 클릭할 수 있다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Codelab_Jetpack_Compose_layout_009

Modifier를 사용하여 좀 더 그럴듯하게 꾸며보자. 전체 영역에 대해 paddingdmf 8.dp를 부여하고 4.dp만큼 round corner를 부여한다. 그리고 배경은 MaterialTheme에 정의된 기본 surface 색상이고 클릭 속성을 부여하며 내부 컨텐츠에 대해 16.dp padding을 또 부여한다.

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Codelab_Jetpack_Compose_layout_010

전체 영역에 대해 바깥 8.dp는 클릭속성이 부여되어 있지 않아 클릭할 수 없다. 역시 round corner를 clickable보다 먼저 부여했기에 클릭 시 음영 처리되는 영역의 모서리도 둥그렇게 처리되었다. 클릭 속성 부여 이후 padding을 16.dp만큼 부여하였고 해당 영역은 음영처리됨을 확인할 수 있다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #2 수정자

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Delegate(위임)는 객체가 직접 작업을 수행하지 않고 다른 곳에서 작업을 하게끔 맡기는 디자인을 의미한다. 코틀린에서는 기본적으로 Delegation Pattern 구현에 필요한 기능을 지원한다. 그리고 비슷한 맥락으로 변수 선언과 동시에 초기화를 하지 않고 변수만 미리 선언하고 초기화를 뒤로 미룰 수 있다. 초기화를 늦추면 사용하지 않을 데이터를 미리 초기화할 필요가 없어 성능향상에 도움이 된다. 그 중에서도 두 가지 방법이 있다.

Lazy Initialization(By Lazy Delegate) : 변수 선언 시 초기화 코드도 함께 정의하며 사용될 때 초기화 코드를 실행하여 초기화

Late Initialization : 필요할 때 초기화하여 사용하나 초기화하지 않고 사용하면 Exception이 발생함

기능적으로 초기화를 지연한다는 점에서 유사하나 초기화 과정에 조금 차이가 발생한다. 좀 더 제약사항과 동작 방식을 정리해보자.

Lazy Initialization

Lazy Initialization을 사용하기 위해선 val 프로퍼티에서만 사용해야 한다. 그리고 by lazy { ... }라는 구문을 사용하여 초기화 구문을 작성해야 한다. Lambda 구문은 변수를 처음 사용할 때 단 한번 호출되며 마지막 값이 초기값으로 할당된다. 아래 예제 구문에서는 foo 변수를 호출 시 초기화 구문에 따라 100이 할당될 것이다.

val foo: Int by lazy {
    println("foo value is 100!")
    100
}

초기화 구문이 단 한번 호출됨을 확인하기 위해 아래 코드를 작성하였다.

class Bar {
    val foo: Int by lazy {
        println("Setting foo 100!")
        100
    }
}

fun main() {
    val buf = Bar()
    println(buf.foo)
    println(buf.foo)
}

실행 결과는 다음과 같이 출력될 것이다. 초기화 블럭에 정의했던 println 구문이 한 번만 실행되었음을 확인할 수 있다.

Setting foo 100!
100
100

Lazy Initialization 내부 동작

public final class Bar {
   @NotNull
   private final Lazy foo$delegate;

   public final int getFoo() {
      Lazy var1 = this.foo$delegate;
      Object var3 = null;
      return ((Number)var1.getValue()).intValue();
   }

   public Bar() {
      this.foo$delegate = LazyKt.lazy((Function0)null.INSTANCE);
   }
}

Bar 클래스 생성 시 {변수이름}$delegate라는 이름을 가진 Lazy 타입의 객체를 선언한다. Bar 클래스 객체를 생성하면서 해당 객체를 정의한다. LazyKt 파일의 lazy 함수를 이용하여 변수에 값을 할당한 뒤 getFoo 함수를 호출하면 객체의 변수를 조회한 뒤 Lazy 타입의 내부 변수에 할당하여 값을 조회하는 함수를 호출하며 리턴한다.

null.INSTANCE는 뭔가요?

직접 컴파일된 파일을 decompile한 경우와는 달리 Kotlin bytecode inspector로 디컴파일을 수행한 경우 실행이 불가능한 코드로 보이는 경우가 있다. 이는 컴파일러가 요구하는 방식과는 다르게 코드를 작성했기 때문인데 따라서 JVM bytecode != Java language specific임을 먼저 알아야 하고 그에 따라 Kotlin generated JVM bytecode != Java generated JVM bytecode라는 사실을 이해해야 한다.

위 사실에 따라 NULL 인스턴스는 디컴파일러가 잘못 인터프리팅한 결과로 값을 제대로 설정하는 항목에서의 모든 참조값이 NULL 인스턴스로 출력될 것이다.

lazy 함수의 thread-safe

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

파라미터로 전달받은 initializer를 사용하여 Lazy 타입의 객체를 생성하는 함수다. 그리고 별도로 입력한 initializer 말고도 기본적으로 적용되는 thread-safe한 mode인 LazyThreadSafetyMode.SYNCHRONIZED를 사용한다. 내부적으로 동기화를 구현하므로 외부에서 동기화 구현을 권장하지 않으며 구현하더라도 데드락 발생에 주의해야 한다.

public final class MainKt {
   public static final void main() {
      Bar buf = new Bar();
      int var1 = buf.getFoo();
      System.out.println(var1);
      var1 = buf.getFoo();
      System.out.println(var1);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

main 함수를 디컴파일한 결과를 보면 Bar 타입 객체를 선언한 뒤 출력할때마다 해당 객체의 getFoo 메소드를 호출하여 출력함을 볼 수 있다.

Lazy Initialization의 특징

  1. val 프로퍼티에서만 사용 가능
  2. 앞에서 기술하였듯, 객체를 생성하고 초기화가 선행되어야 함에 따라 Immutable한 변수인 val 프로퍼티에서만 사용할 수 있다. 그리고 내부적으로 final 키워드를 사용함에 따라 immutable을 보장해야 함을 알 수 있다.
  3. Thread-safe함
  4. 내부적으로 초기화 시 thread-safe한 모드를 사용함에 따라 lazy 함수를 별도로 overloading하지 않는 이상 thread-safe하다. 따라서, initializer가 최대한 단 한 번 수행됨을 보장할 수 있다.
  5. Java primitive type(Int, Boolean) 사용 가능
  6. Non-null, Nullable 사용 가능

Lazy Initialization의 초기화 확인

Lazy Initialization 변수의 초기화 여부를 확인할 수 있다.

    val lazyFoo = lazy{
        100
    }
    val foo by lazyFoo
    println(foo) // use foo variable (initialize lazyFoo)
    if (lazyFoo.isInitialized()) {
        println(foo)        
    }

우선 lazyFoo 변수를 lazy 키워드를 사용하여 할당하였다. 다만 이전에 초기화했던 방식과는 달리 by 키워드를 사용하지 않아 Lazy<Int> 타입이 할당되었다. 그리고 foo라는 추가 변수를 선언하고 by 키워드를 사용하여 lazyFoo의 초기화 구문을 사용하여 값이 할당되었다. 초기화 여부는 Lazy 타입 변수에 대해 isInitinalized() 함수를 사용하여 확인할 수 있다.

다만, 값 조회 시 Lazy 객체와 값이 서로 분리된다는 점이 마음에 조금 걸린다. 한번에 해결하기 위해 Lazy 객체를 직접 사용하는 방식도 있다.

    val lazyFoo = lazy{
        100
    }
    val foo by lazyFoo
    if (lazyFoo.isInitialized()) {
        println(lazyfoo.value)
    }

특정 상황에서의 안전보장 목적이 아니라면 실질적으로 lazy 키워드와 by 키워드를 모두 사용하여 한번에 변수 호출과 동시에 초기화까지 진행되게끔 하는 것이 훨씬 더 효율적이다.

Late Initialization

Late Initialization을 사용하기 위해선 var 프로퍼티에서만 사용해야 한다. 그리고 lateinit라는 키워드를 사용한다. 초기화 구문을 사용하지 않고 나중에 값을 할당하여 초기화를 수행할 수 있다. 변수를 사용하지 않는 한 컴파일러에게 나중에 초기화하겠다고 전달하므로 컴파일 시 에러가 발생하지 않는다. 아래 예제 구문에서는 Temp 타입의 foo 변수를 lateinit 키워드를 사용하여 우선 선언한 뒤 initFoo함수에 파라미터를 전달하여 값을 할당한다.

class Bar {
    lateinit var foo: Temp
    fun initFoo(param: Temp): Unit {
        this.foo = param
    }
}

class Temp(val bar: Int)

fun main() {
    val buf = Bar()
    buf.initFoo(Temp(100))
    println(buf.foo.bar)
}

실행결과는 100이 출력될 것이다. initFoo 함수를 호출하지 않고 변수를 참조하면 아직 값이 할당되지 않았기 때문에 Exeception이 발생할 것이다.

fun main() {
    val buf = Bar()
    println(buf.foo.bar)
    buf.initFoo(Temp(100))
}
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property foo has not been initialized
    at Bar.getFoo(Main.kt:2)
    at MainKt.main(Main.kt:12)
    at MainKt.main(Main.kt)

초기화되지 않은 프로퍼티를 참조했기에 UninitializedPropertyAccessException이 발생함을 볼 수 있다.

Late Initialization 내부 동작

아래 코드는 Bar 클래스를 디컴파일한 코드이다.

public final class Bar {
   public Temp foo;

   @NotNull
   public final Temp getFoo() {
      Temp var10000 = this.foo;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("foo");
      }

      return var10000;
   }

   public final void setFoo(@NotNull Temp var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.foo = var1;
   }

   public final void initFoo(@NotNull Temp param) {
      Intrinsics.checkNotNullParameter(param, "param");
      this.foo = param;
   }
}
  1. var에서만 사용

우선 foo 변수가 public Temp foo로 선언되어 있음을 볼 수 있다. 즉, lazy initialization과는 달리 수정할 수 있으므로 var 타입으로만 사용해야 함을 확인할 수 있다.

  1. Non-null 프로퍼티로만 사용 가능

추가로 변수를 조회하는 함수인 getFoo를 보면 foo 변수를 로컬 변수에 할당한 뒤 값을 점검하는데 null 체킹을 하고 있음을 확인할 수 있다. 위 로직에 따라 null을 할당할수 없음을 알 수 있겠다. null을 사용할 수 없는 이유는 Intrinsics.checkNotNullParameter로도 확인할 수 있다. 해당 함수의 내부 구현 코드는 다음과 같다.

    public static void checkNotNullParameter(Object value, String paramName) {
        if (value == null) {
            throwParameterIsNullNPE(paramName);
        }
    }

위 코드에 따라 넘겨받은 파라미터의 값이 null인 경우 Execpetion을 throw하고 있음을 볼 수 있다. 따라서, 조회뿐만 아니라 초기화와 이후에 값을 변경할 때에도 null 값은 할당할 수 없다. 실제로 아래 코드를 작성하면 컴파일 단계부터 에러가 발생한다.

lateinit var foo: Temp?  // compile level error
  1. Custom getter/setter 생성 불가

lateinit으로 선언된 변수는 Custom getter/setter 생성이 불가능하다. 만약 작성하더라도 컴파일 단계에서 에러가 발생한다.

lateinit var foo: Temp?
  get() {
      foo;
  }

그 이유로 lateinit으로 선언한 변수에는 값을 저장하는 backing field가 존재한다. 그리고 lateinit backing field는 노출되어 있기 때문에 원칙적으로 Custom getter/setter를 금지하고 있다.

  1. Java primitive type(Int, Boolean)은 사용이 불가함

Non-null 프로퍼티로만 사용 가능한 이유와 연결된다. lateinit은 null을 변수가 초기화되었는지 그렇지 않는지 판별하기 위한 특별한 값으로 사용한다. 따라서, lateinit 변수에 값을 덮씌우는 것은 가능할지언정 null값의 명시적 선언은 불가하다. 만약 아래 코드가 있다고 해보자.

private lateinit var x: Int

코틀린 컴파일 시 x는 int타입으로 컴파일되어야 한다. 그리고 lateinit 변수이기 때문에 null을 초기화되었는지 판별하기 위해 사용한다. 그러나 null값을 위 코드에 선언한 Int 타입 변수나 다른 primitive 타입에 저장할 수 없다. 만약 null 저장을 위해 아래 코드처럼 변경해보자.

private lateinit var x: Int?

만약 null을 변수에 저장할 수 있도록 허용하면 초기화되지 않은 경우 특별한 holder로써 null의 동작에 문제가 발생한다. 그렇기 때문에 lateinit 변수에 nullable type을 사용할 수 없다. 그리고 lateinit은 변수 타입을 명확히 하고 null 타입 적용의 모호함을 피하기 위한 목적도 있다. 그렇기에 primitive 타입을 nullable하게 사용함은 lateinit의 목적성을 해친다. 만약 내부적으로 타입별 기본값을 초기화되지 않은 변수 판별에 사용하지 않는 한 null과 primitive type이 허용될 일은 없으며 기본값을 사용하더라도 해당 값이 실제 할당될 값일 수 있기에 문제가 발생한다.

Late Initialization의 특징

  1. var 프로퍼티에서만 사용 가능
  2. Non-null프로퍼티만 사용 가능
  3. Custom getter/setter 생성 불가
  4. Java primitive type(Int, Boolean) 사용 불가

Late Initialization의 초기화 확인

코틀린은 Late Initailization이 완료되었는지 확인을 위해 isInitialized 프로퍼티를 제공한다.

lateinit var file: File
if (this::file.isInitialized) {
    /* ... */
}

lateinit 변수가 선언된 범위 내에서 this 키워드를 사용하여 초기화되었는지 확인 후 특정 동작을 정의할 수 있다.

By Lazy Delegate와 Late Initialization의 장단점

Lazy Initialization의 장단점

  1. (장점) 초기화 단계에서 Thread-safe를 보장하여 유저가 다중 쓰레드 환경에서의 초기화를 신경쓰지 않아도 된다.
  2. (장점) by 키워드를 사용하여 초기화를 한번에 정의할 수 있으므로 initialize 여부를 신경쓰지 않아도 된다.
  3. (장점?) Lazy 타입 인스턴스를 저장할 수 있기에 다른 곳으로 전달이 가능하다.
    • "가능"하다고 장점으로 단언하기 애매한 특징이다. 되려 오용하여 side-effect가 발생할 수 있기 때문이다.
  4. (단점) by lazy { ... }구문을 사용하여 전달된 lambda가 context가 가진 레퍼런스를 들고있을 위험이 존재한다.
    • Android에서 사용 시 lifecycle 객체가 해제되지 않는 문제가 발생할 수 있어 initializer lambda 함수 내부에서 사용되는 객체들에 주의를 기울여야 한다.

Late Initialization의 장단점

  1. (장점) Non-null 프로퍼티만을 허가하여 null허용에 따른 타입 모호성을 해결한다.
  2. (장점) Android의 경우 선언 위치는 상위이나 초기화는 하위 레벨에서 진행해야 하는 경우에 유용하다.
    • 뷰 관련 변수를 선언할 때 특히 많이 사용된다.
  3. (단점?) Lazy 타입과는 달리 lateinit은 오로지 initialize 여부에 따른 null을 저장할 뿐, 인스턴스를 저장할 수 없다.
    • 오히려 이렇게 제약을 걸어버리는 게 장점으로 작용될 수 있어 애매하다.
  4. (단점) 멀티 스레딩 환경에서의 initialization의 안전성은 오로지 유저 코드가 책임진다.

결론

성능에 도움이 된다고 모든 변수에 대해 초기화를 지연함은 코드 관리 측면에서 바람직하지 않다. 특정 변수가 여러 곳에서 쓰이고 참조값이 변경될 일이 없으며 초기화가 단 한번만 발생해야 하는 경우라면 Lazy Initialization을 사용할 수 있겠다. 변수가 여러 곳에서 쓰이나 초기화 코드를 변수 선언시점에서 정의할 수 없다면 Late Initialization을 사용함이 옳다.

참고자료

  1. Stack Overflow - Why Kotlin decompiler generates null.INSTANCE
  2. Baeldung - Why Kotlin lateinit Can’t Be Used With Primitive Types

+ Recent posts