SW 공부노트

[안드로이드/Kotlin] Jetpack Compose 레이아웃 실습 본문

안드로이드/안드로이드 실습

[안드로이드/Kotlin] Jetpack Compose 레이아웃 실습

요빈 2023. 4. 17. 21:11

https://developer.android.com/codelabs/jetpack-compose-layouts?hl=ko&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%3Fhl%3Dko%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#0 

 

Compose의 기본 레이아웃  |  Android Developers

이 Codelab에서는 Compose에서 즉시 제공되는 컴포저블과 수정자를 통해 실제 디자인을 구현하는 방법을 알아봅니다.

developer.android.com

본 글은 위 과정을 공부하며 작성한 글입니다.


왼쪽 화면을 구현하려 한다.

해당 UI를 여러 개의 재사용 가능한 부분으로 나누려면 크게 3개로 나눌 수 있다.

 

1. 가장 높은 추상화 수준

   - 화면의 콘텐츠

   - 하단 탐색

 

2. 화면 콘텐츠

   - 검색창

   - ALIGN YOUR BODY

   - FAVORITE COLLECTIONS

 

3. 화면 콘텐츠 내 하위 요소

   - 가로로 스크롤되는 원 컴포넌트

   - 가로로 스크롤되는 직사각형 컴포넌트

 

이렇게 디자인 분석이 완료되면 가장 하위 요소인 컴포저블부터 시작해 이를 조합하여 복잡한 컴포저블을 구현한다.


1. 검색창

검색창을 구현하려면 TextField라는 Material 컴포넌트를 사용한다.

TextField는 Compose Material 라이브러리에 포함되어 있다.

 

수정자(modifier)를 통해 컴포저블을 수정해 컴포저블의 디자인, 느낌, 동작을 조정할 수 있다.수정자의 역할은 다음과 같다.

 

  • 컴포저블의 크기, 레이아웃, 동작, 모양 변경
  • 접근성 라벨과 같은 정보 추가
  • 사용자 입력 처리
  • 요소에 상호작용(클릭/스크롤/드래그 가능 또는 확대/축소) 추가

여러 수정자 메서드를 연결하면 복잡한 설정도 가능하다.

@Composable
fun SearchBar(
    modifier: Modifier = Modifier
) {
    TextField(
        value = "",
        onValueChange = {},
        leadingIcon = {
            Icon(imageVector = Icons.Default.Search,
                contentDescription = null)
        },
        colors = TextFieldDefaults.textFieldColors(
            backgroundColor = MaterialTheme.colors.surface
        ),
        placeholder = { Text(stringResource(id = R.string.placeholder_search)) },
        modifier = modifier
            .heightIn(min = 56.dp)
            .fillMaxWidth()
    )
}
  • 배경색 설정 속성인 colors에서 TextFieldDefaults 색상 클래스를 사용해 차이가 있는 색상만 업데이트하였다.
  • 텍스트 필드에 고정 높이가 아닌 최소 높이를 설정해 사용자 별 글꼴 크기에 따라 크기가 변할 수 있다. -> 권장되는 방식

2. ALIGN YOUR BODY 컴포넌트

해당 컴포넌트는 다음과 같은 디자인이다.

 

 

Image 및 Text 컴포저블을 세로로 배치하기 위해 Column에 포함한다.

 

- 기준선

텍스트의 기준선이란 문자가 놓여있는 선을 가리킨다. paddingFromBaseline 수정자를 통해 기준선을 기준으로 패딩을 지정할 수 있다.

 

- 정렬

일반적으로 상위 컨테이너 내부에서 컴포저블을 정렬하려면 상위 컨테이너의 alignment를 설정한다.즉, 상위 요소에 하위 요소를 정렬하는 방법을 지시한다.

 

하위 요소를 가로로 정렬하는 경우, 컴포저블 별 옵션은 다음과 같다.

 

  • Column(가로 정렬, horizontalAlignment): Start, CenterHolrizontally, End
  • Row(세로 정렬, verticalAlignment): Top, CenterVertically, Bottom
  • Box(가로 및 세로 정렬 결합):  Top(Center, Bottom)Start(CenterEnd) -> 총 9개

컨테이너의 모든 하위 요소가 동일한 정렬 패턴을 따른다.

align 수정자를 통해 단일 하위 요소의 동작을 재정의할 수 있다.

@Composable
fun AlignYourBodyElement(
    modifier: Modifier = Modifier,
    @DrawableRes drawable: Int,
    @StringRes text: Int
) {
    Column(modifier = modifier,
        horizontalAlignment = CenterHorizontally) {
      Image(
          painter = painterResource(id = drawable),
          contentDescription = null,
          contentScale = ContentScale.Crop,
          modifier = Modifier
              .size(88.dp)
              .clip(CircleShape)
      )
      Text(text = stringResource(id = text),
          style = MaterialTheme.typography.h3,
          modifier = Modifier
              .paddingFromBaseline(top = 24.dp, bottom = 8.dp)
      )
    }
}

3. FAVORITE COLLECTIONS 컴포넌트

해당 컴포넌트는 다음과 같은 디자인이다.

 

이 컨테이너는 배경과 다른 색이 지정되어 있으며, 둥근 모서리를 가진다.

배경 위에 컴포넌트를 담을 컨테이너가 필요한 경우 Material의 Surface 컴포저블을 사용한다.

 

여기서는 모서리를 둥글게 처리해야 하는데 이를 위해 shape 매개변수를 사용한다.

(이전 예제에서는 Image 컴포저블에 clip 수정자 통해 원 모양 이미지 생성) 

@Composable
fun FavoriteCollectionCard(
    modifier: Modifier = Modifier,
    @StringRes text: Int,
    @DrawableRes drawable: Int
) {
    Surface(
        modifier = modifier,
        shape = MaterialTheme.shapes.small
    ) {
        Row(modifier = Modifier.width(192.dp),
            verticalAlignment = Alignment.CenterVertically
        ){
            Image(
                painter = painterResource(drawable),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier.size(56.dp)
            )
            Text(
                text = stringResource(text),
                style = MaterialTheme.typography.h3,
                modifier = Modifier.padding(horizontal = 16.dp)
            )
        }
    }
}
  • Surface - shape :  둥근 모서리
  • Row - verticalAlignment : 세로 정렬

4. 스크롤 가능한 행(LazyRow)

스크롤 가능한 "ALIGN YOUR BODY" 행의 디자인은 다음과 같다.

 

LazyRow 컴포저블을 사용해 위와 같이 스크롤 가능한 행을 구현할 수 있다.

LazyRow는 모든 요소를 동시에 렌더링하지 않고, 화면에 표시되는 요소만 렌더링해 앱의 성능을 유지한다.

LazyRow의 하위요소는 컴포저블이 아니고, 컴포저블을 리스트로 내보내는 item 및 item 메서드를 제공하는 DSL을 사용한다.

 

* 교차축: 자신의 기본축에 수직인 축(ex. Column의 기본축은 세로, 교차축은 가로 / Row의 기본축은 가로, 교차축은 세로)

간격을 구현하려면 배치에 대해 알아야 한다. 속성명은 정렬(Alignment)과 비슷하다.

 

  • Column 배치 : verticalArrangement
  • Row 배치 : horizontalArrangement

Row의 경우 다음과 같은 배치를 선택할 수 있다. (Column은 세로로 세우면 됨)

이러한 배치 외에도 Arrangement.spacedBy() 메서드를 통해 하위 컴포저블 사이에 고정된 공간을 추가할 수 있다.

 

 

@Composable
fun AlignYourBodyRow(
    modifier: Modifier = Modifier
) {
    LazyRow(
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(horizontal = 16.dp),
        modifier = modifier)
    {
        items(alignYourBodyData){item ->
            AlignYourBodyElement( drawable = item.drawable, text =item.text)
        }
    }
}
  • horizontalArrangement = Arrangement.sapcedBy(): 리스트 내 요소의 고정된 간격 지정
  • contentPadding: 동일한 패딩을 유지하되 상위 목록 경계에서 콘텐츠가 잘리지 않고 스크롤 될 수 있게 설정
  • LazyRow 형식 -> items에 출력할 데이터(리스트) 넣어 람다식 통해 각 컴포넌트 생성

5. 스크롤 가능한 그리드(LazyHorizontalGrid)

다음으로 구현할 부분은 단일 행이 아닌 그리드이다.

이 부분은 단일 행을 출력하는 LazyRow를 만들고, LazyRow의 각 항목이 두 개의 직사각형 컴포넌트를 갖는 Column을 갖는 방식으로 구현한다. 여기서 항목 - 그리드 요소 매핑을 더 효과적으로 지원하는 LazyHorizontalGrid를 사용한다.

@Composable
fun FavoriteCollectionsGrid(
    modifier: Modifier = Modifier
) {
    LazyHorizontalGrid(
        rows =  GridCells.Fixed(2),
        contentPadding = PaddingValues(horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        modifier = modifier.height(120.dp)
    ){
        items(favoriteCollectionsData){ item ->
            FavoriteCollectionCard(
                text = item.text,
                drawable = item.drawable,
                modifier = Modifier.height(56.dp))
        }
    }
}
  • LazyHorizontalGrid - rows: 그리드 행 수 지정
  • 수직/수평 Arrangement: LazyRow/Column과 달리 수직/수평 배치 모두 지정

6. 홈 화면 - 슬롯 API

앱 홈 화면에는 동일한 패턴을 따르는 여러 개의 섹션이 있다.

각각 제목이 있으며 섹션에 따라 다른 콘텐츠가 표시된다. 홈 화면 디자인은 다음과 같다.

이 유연한 섹션 컨테이너를 구현하려면 슬롯 API를 사용한다.

슬롯 API는 Compose가 컴포저블 위에 맞춤설정 레이어를 배치하기 위해 도입한 패턴이다.

슬롯 기반 레이아웃은 개발자가 원하는 대로 채울 수 있도록 UI에 빈 공간을 남겨둔다.

컴포저블은 일반적으로 content 컴포저블 람다를 사용한다. 

슬롯 API는 특정 용도를 위해 여러 content 매개변수를 사용한다.

 

즉, 동일한 패턴을 따르는 여러 섹션을 슬롯 API를 통해 재사용 가능한 컴포저블 하나로 구현할 수 있다.

아래 HomeSection 컴포저블이 매개변수로 받는 content에 유의하자.

@Composable
fun HomeSection(
    @StringRes title: Int,
    modifier: Modifier = Modifier,
    content: @Composable ()->Unit
) {
    val titlePadding = Modifier
        .paddingFromBaseline(bottom = 8.dp, top = 40.dp)
        .padding(horizontal = 16.dp)
    
    Column(modifier = modifier) {
        Text(
            text = stringResource(title).uppercase(Locale.getDefault()),
            style = MaterialTheme.typography.h2,
            modifier = titlePadding
        )
        content()
    }
}

7. 홈 화면 - 스크롤

모든 개별 컴포넌트를 만들었으니 이제 이들을 전체 화면으로 결합할 차례이다.

결합할 전체 디자인은 다음과 같다.

 

검색창 아래에 하나의 섹션을, 그 아래에 또 하나의 섹션을 배치하면 된다.

디자인과 동일하게 만들려면 간격을 추가해야한다.

Spacer를 사용하면 Column 내부에서 더 많은 공간을 확보할 수 있다.

Spacer 대신 Column 내에서 padding을 사용하면 콘텐츠가 잘리는 문제가 발생할 수 있다.

 

위 화면은 대부분의 기기에서 제대로 표현되겠지만, 가로 모드와 같이 높이가 작을 경우에 대비해서 세로 방향으로 스크롤할 수 있어야 하기 때문에 스크롤 동작을 추가해야 한다.

이전 예제에서 사용한 LazyRow/LazyHorizontalGrid와 같은 Lazy 레이아웃은 자동으로 스크롤 동작을 추가한다.

일반적으로, 리스트에 포함된 요소가 많거나 로드해야 할 데이터 세트가 많아 모든 항목을 동시에 내보내면 성능이 저하되고, 앱이 느려지는 경우에 Lazy 레이아웃을 사용한다.

 

하지만 리스트에 포함된 요소가 많지 않은 경우에는 간단한 Column 및 Row에 verticalScroll 및 horizontalScroll 수정자를 적용스크롤 동작을 수동으로 추가하면 된다. 이를 위해서는 스크롤의 현재 상태를 포함하며 외부에서 스크롤 상태를 수정하는 데 사용되는 ScrollState가 필요하다. 현재 예제에서는 스크롤 상태를 수정할 필요가 없으므로 rememberScrollState를 사용해 영구 ScrollState 인스턴스를 만들면 된다.

@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
    Column(modifier = modifier
        .verticalScroll(state =  rememberScrollState())
        .padding(vertical = 16.dp)
    ) {
        Spacer(modifier = Modifier.height(16.dp))
        SearchBar(Modifier.padding(horizontal = 16.dp))
        HomeSection(title = R.string.align_your_body) {
            AlignYourBodyRow()
        }
        HomeSection(title = R.string.favorite_collections) {
            FavoriteCollectionsGrid()
        }
        Spacer(modifier = Modifier.height(16.dp))
    }
}
  • Spacer : 빈 공간 추가
  • verticalScroll : 수동으로 스크롤 추가 -> ScrollState

8. 하단 탐색

하단 탐색 컴포저블을 구현한 후 전체 화면에 결합한다. 디자인은 다음과 같다.

 

하단 컴포저블은 Compose Material 라이브러리에서 제공하는 BottomNavigation 컴포저블을 사용하면 된다.

BottomNavigation 컴포저블 내에서 하나 이상의 BottomNavigationItem 요소를 추가하면 라이브러리에 의해 자동으로 스타일이 지정된다. 

@Composable
private fun SootheBottomNavigation(modifier: Modifier = Modifier) {
    BottomNavigation(
        backgroundColor = MaterialTheme.colors.background,
        modifier = modifier) {
        BottomNavigationItem(
            icon = { Icon(
                imageVector = Icons.Default.Spa,
                contentDescription = null)
            },
            label = {Text(stringResource(id = R.string.bottom_navigation_home))},
            onClick = {},
            selected = true
        )
        BottomNavigationItem(
            icon = { Icon(
                imageVector = Icons.Default.AccountCircle,
                contentDescription = null)
            },
            label = {Text(stringResource(id = R.string.bottom_navigation_profile))},
            onClick = {},
            selected = true
        )
    }
}
  • BottomNavigationItem 매개변수 : icon, onClick, selected(필수 3개) + label

9. 최종 화면 구현

마지막 단계에서는 하단 탐색을 포함하는 전체 화면을 구현한다.

Material의 Scaffold은 Material Design을 구현하는 앱을 위한 최상위 수준의 컴포저블을 제공한다.

여기에는 다양한 Material 개념의 슬롯이 포함되어 있는데, 그 중 하나가 하단 메뉴이다.

하단 메뉴 안에 앞서 만든 하단 탐색 컴포저블을 배치하면 된다.

 

앱의 최상위 수준 컴포저블인 Scaffold를 구현하기 위해 다음 과정을 진행해야 한다.

 

  • 프로젝트용 Material 테마 적용
  • Scaffold 추가
  • 하단 메뉴에 하단 탐색 컴포저블(BottomNavigation) 적용
  • 콘텐츠에 메인 화면(HomeScreen) 컴포저블 설정
@Composable
fun MySootheApp() {
    MySootheTheme{
        Scaffold(bottomBar = { SootheBottomNavigation() }
        ) { padding ->
            HomeScreen(Modifier.padding(padding))
        }
    }
}

 

* 수정자 메서드

- wrapContentWidth(Alignment.CenterHorizontally) : 가운데 정렬

- align(Alignment.CenterHorizontally) : 가운데 정렬

- padding(horizontal(vertical), top/bottom 등) : 패딩

- heightIn(min, max) : 높이 최대/최소 지정

- fillMaxWidth() : 화면 크기에 너비 맞춤

- size : 특정 크기에 맞게 컴포저블 조정

- clip : 컴포저블 모양 조정 -> Shape 설정

- align: 단일 하위 요소의 정렬 정의

- paddingFromBaseLine(top = 2.dp, bottom=2.dp) : 텍스트 기준선으로부터의 패딩 정의

- vertical/horizontalScroll : 스크롤

 

- 자료형이 () -> Unit이면 람다식을 의미하므로 { } (중괄호) 안에 값 입력하기

- compose의 접근성? 화면에 표시된 내용을 특정한 요구사항이 있는 사용자에게 적합한 형식으로 변환하는 데 사용

- 최상위 컴포저블에 매개변수로 사용되는 Modifier는 바로 하단의 컴포넌트에만 적용