[Android/Compose] Picker, NumberPicker, DatePicker 제작 과정기 1부

곽의진·2024년 8월 14일
1

그렇습니다... 🥲
가끔 우리는 안드로이드 기본 컴포넌트에 없는 컴포넌트를 만들어야 할때가 있죠.

오늘은 조금 까다로웠던 Year, Month를 선택할 수 있도록 하는 DatePicker를 만드는 과정을 보여드릴려고 합니다.

일단 코드 분석 단계가 있기 때문에 (굉장히) 조금 많이 깁니다.
버티실 수 있는 강한 분만 읽으시고 아니면 그냥 제 레포에서 복붙해가십쇼.

https://github.com/KwakEuiJin/Compose-DatePicker

⚒️ Compose Picker 제작 동기

먼저 요구되는 디자인 가이드를 살펴보시죠

🖼️ 디자인 가이드

해당 사진은 디자인 가이드상 한 부분인 DatePicker입니다.

간단히 보면 Year, Month값을 NumberPicker로 각각 구현하면 될 것 같다고 생각하실겁니다.

문제점

하지만 Compose에서는 아쉽게도 NumberPicker를 지원하지 않습니다, 따라서 저희가 따로 커스텀한 Composable을 개발해야합니다.

저는 처음부터 컴포넌트를 만들어 나갈 정도의 실력이 없기 때문에 다음 단계를 거쳐 DatePicker를 구현해 볼 예정입니다.

구현 단계

  1. stackoverflow에서 찾은 예시를 토대로 Picker를 구현할 것입니다.
    관련 링크: https://stackoverflow.com/questions/68187868/android-jetpack-compose-numberpicker-widget-equivalent
  2. 해당 Picker를 활용하여 Year, Month Picker 각각 구현합니다.
  3. 이를 통해 디자인 가이드를 충족합니다.

스택오버플로우 예시 코드 분석

코드가 너무 길기 때문에 위 참고 링크를 통해 확인 부탁드립니다.
최대한 간단히 설명해보겠습니다.

Picker UI 구조 설명

Picker UI는 화면에 리스트 형태로 아이템들을 표시하고, 사용자가 스크롤하여 원하는 항목을 선택할 수 있도록 만든 컴포저블입니다.

많은 코드 중 Picker 기능의 가장 핵심적인 LazyColumn만 짚고 넘어가보겠습니다.

@Composable
LazyColumn(
    state = listState,
    flingBehavior = flingBehavior,
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier
        .fillMaxWidth()
        .height(itemHeightDp * visibleItemsCount)
        .fadingEdge(fadingEdgeGradient)
) {
    items(listScrollCount) { index ->
        Text(
            text = getItem(index),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            style = textStyle,
            modifier = Modifier
                .onSizeChanged { size -> itemHeightPixels.value = size.height }
                .then(textModifier)
        )
    }
}

LazyColumn은 리스트 형태로 아이템들을 세로로 배치하는 역할을 합니다. 이 부분이 Picker의 핵심입니다. 사용자가 이 리스트를 스크롤하면, 리스트 항목들이 위아래로 움직이며 선택할 수 있습니다.

flingBehavior

여기서 flingBehavior라는 생소한 녀석을 볼 수 있는데요!!
flingBehavior란 Compose에서 스크롤 가능한 UI 요소(예: LazyColumn 또는 LazyRow)에서 사용자의 스크롤 동작을 제어하고, 스크롤이 멈출 때 아이템이 특정 위치에 정렬되도록 하는 중요한 역할을 합니다.

이는 특히, Picker UI에서 사용자가 스크롤할 때 스냅(Snap) 동작을 구현하는 데 필수적이죠

아래는 flingBehavior를 제거한 예시 영상입니다, 동작을 보시면 아시겠지만 Picker라고 하기엔 명시적인 아이템을 선택하지 못하는 것을 볼 수 있습니다.

즉 우리는 flingBehavior를 통해 사용자가 스크롤을 멈출 때 애매하게 아이템이 멈추는 것이 아닌 컴포넌트 중앙에 요소가 정렬되도록 하여 어떤 숫자가 선택되었는지를 사용자에게 명시적으로 보여줄 수 있는 것을 알았습니다

fadingEdgeGradient

fadingEdgeGradient는 Picker UI에서 리스트의 상단과 하단 부분에 부드러운 페이딩 효과를 적용하기 위해 사용됩니다.

이 효과는 위 영상에서 보이는 것 처럼 사용자가 스크롤할 때 리스트의 양 끝부분이 서서히 사라지게 보여, 시각적으로 부드럽고 깔끔한 느낌을 주는 역할을 합니다.

height

LazyColumn의 높이 계산식은 다음과 같습니다

height(itemHeightDp * visibleItemsCount)

이를 통해 위 사진처럼 visibleItemsCount(예시: 3)에 해당하는 값만 보이도록 하여 Picker의 UI를 구현할 수 있는 것입니다.

PickerState

다음은 PickerState를 봅시다.

class PickerState {
    var selectedItem by mutableStateOf("")
}

그냥 별거 없이 선택된 아이템을 저장하는 클래스입니다.
이를 recomposition 상황에서도 유지되도록 remember로 묶은 형태를 rememberPickerState() 로 정의했을 뿐입니다.

🕐 Picker 활용하기

PickerExample 설명

해당 스택오버플로우의 예제를 보면 Row를 사용하여 수평으로 두개의 Picker Composable을 배치하였으며, 각각의 Picker에서 선택한 아이템을 가져오기 위해 2개의 State를 선언한 것을 확인할 수 있습니다.

또한 각 Picker에 들어갈 item을 정의하기 위해 List<String> 형태의 변수를 선언해두었습니다.

val values = remember { (1..99).map { it.toString() } }
val valuesPickerState = rememberPickerState()
val units = remember { listOf("seconds", "minutes", "hours") }
val unitsPickerState = rememberPickerState()

여기까지 스택오버플로우에서 제시해준 Picker를 분석해보았습니다.
좀 길었는데요, 그래서 제가 이 Picker를 어떻게 커스텀해서 제 디자인 가이드에 알맞게 변경했는지 확인해보시죠.

실제 디자인 가이드에 알맞게 Picker를 커스텀

Picker 디자인 변경

먼저 년도(Year), 월(Month)를 나타내기 위해서는 2개의 Picker가 필요합니다

특히 예제에는 이미 2개의 Picker가 있기 때문에 크게 어려운 점은 없었습니다...? 과연 그랬을까요?

하하 일단 같이 살펴보시죠

Divider

아래 디자인 가이드를 보시면 Year, Month 모두 Divider가 고정 dp값을 가진 컴포넌트 입니다.

즉 예제와 같이 fillMaxWidth가 아니며 중앙정렬이 필요하다는 것입니다.

저는 이러한 문제를 해결하기 위해 Box로 Divider를 한번 더 감싼 후 이를 Alignment.Center를 통해 상위 Box의 중앙에 Divider가 위치할 수 있도록 했습니다.

이때 y값의 offset을 조정하는 코드가 빠진 것을 볼 수 있는데요. 이 또한 Divider를 Box 내부에서 Top, Bottom에 제약을 걸고 Box자체의 height를 itemHeightDp로 잡아 더욱 간단하게 Picker에서 선택된 아이템을 나타내는 Divider를 구현했습니다.

Box(
    modifier = Modifier
        .align(Alignment.Center)
        .fillMaxWidth()
        .height(itemHeightDp)
) {
    HorizontalDivider(
        color = dividerColor,
        thickness = 2.dp,
        modifier = Modifier
            .fillMaxWidth()
            .background(
                color = dividerColor,
                shape = RoundedCornerShape(10.dp)
            )
            .align(Alignment.TopCenter)
    )

    HorizontalDivider(
        color = dividerColor,
        thickness = 2.dp,
        modifier = Modifier
            .fillMaxWidth()
            .background(
                color = dividerColor,
                shape = RoundedCornerShape(10.dp)
            )
            .align(Alignment.BottomCenter)
    )
}

2부에 계속...

profile
Android Developer

0개의 댓글