본문 바로가기
Develop/Android

[Android] BottomSheet 디자인시스템 구현기 (Material 뜯어보기)

by bona.com 2025. 1. 29.

BottomSheet를 디자인시스템으로 구현하면서 Material의 동작 방식을 공부했던 것에 대해 기록하고자 한다.

해당 디자인시스템 컴포즈로 작성이 되어 있으며 Material3 라이브러리를 사용하지 않는다.

 

BottomSheetType

우선, Maeterial과는 상관 없는 바텀시트의 디자인이다. 

내가 만드려는 디자인시스템의 바텀시트 타입은 두 가지였다. 

  • 버튼이 한 개 있는 OneButton 타입
  • 버튼이 두 개 있는 TwoButton 타입

 

각 타입에 대한 각 버튼의 텍스트를 무조건 작성해 줘야 되기 때문에 Sealed Class로 묶어 작성했다.

sealed class BottomSheetType {
    data class OneButton(
        val buttonText: String
    ) : BottomSheetType()

    data class TwoButton(
        val firstButtonText: String,
        val secondButtonText: String
    ) : BottomSheetType()
}

 

그리고 이를 아래에서 설명할 BottomSheet 함수의 BottomSheetType 타입의 매개변수로 넘겨주었다.

 

BottomSheet

바텀시트에서 UI 부분을 담당하는 BottomSheet 함수에 대해 설명하겠다. 

Material이 어떻게 구성되어 있는지 먼저 설명하고, 내가 이를 적용한 방식에 대해서 차례로 설명할 것이다.

 

1. 매개변수

Material의 ModalBottomSheet 함수

위 사진은 기존 Material의 바텀시트 함수이다.

아주 많은 매개변수가 존재하는 것을 볼 수 있다. 그러나 디자인시스템에서는 확장 가능한 부분만 매개변수로 넣어줘야 하기 때문에 필요한 부분만 가져왔다. 

(containerColor나 scrimColor, dragHandle 같은 것들은 디자인시스템 상에서 고정이기 때문)

내가 만든 BottomSheet 함수

이제 내가 작성한 코드이다.

바텀시트가 닫힐 때 콜백해줘야 하는 함수인 onDismissRequest, 바텀시트 타입을 정의해줘야 하는 BottomSheetType, 바텀시트 내부 UI를 결정하는 content는 필수 매개변수로 넣어주었고, 나머지는 필요에 따라 넣도록 해줬다.

 

2. 바텀시트 동작 방식

Material의 ModalBottomSheet 함수

Material에서는 다음과 같이 구성되어 있다.

  • ModealBottomSheetPopup을 사용해서 바텀시트를 띄운다.
  • 그리고 BoxWithConstraints를 이용해 바텀시트의 크기를 계산한다.
    • fullHeight 변수를 정의할 때 constraints를 가져와서 사용해주는데, 이는 BoxWithConstraints의 Scope 안에서 사용할 수 있는 변수 중 하나이다.
  • Scrim을 통해 바탕화면에 어두운 색상을 지정해준다.
    • Scrim이 구성되어 있는 방식에 대해서는 글이 길어질 것 같아 생략하겠다. 그냥 자연스럽게 어두운 색상이 적용되고 사라지는 애니메이션이 구현되어 있다고 생각하면 된다.
    • 바탕을 클릭하면 animateToDismiss를 호출하도록 작성되어 있다. animateToDismiss는 비동기적으로 바텀시트를 닫는 동작을 하는 변수로 아래와 같다.
val animateToDismiss: () -> Unit = {
        scope.launch { sheetState.hide() }.invokeOnCompletion {
            if (!sheetState.isVisible) onDismissRequest()
        }
    }
  • 이제 바텀시트의 Surface를 구현하고 있는 것을 볼 수 있다.
    • Surface의 Modifier 속성으로는 드래그의 offset 조정 등 세부 기능을 수행할 수 있는 코드들이 적혀있다.
    • Surface 안에는 드래그하는 핸들을 담당하는 dragHandle 컴포저블 함수와 슬롯으로 넣어준 content가 들어간다.

내가 만든 BottomSheet 함수

이제 Material에서 내가 수정해서 적용한 부분에 대해서만 설명하겠다.

  • 단순 Popup을 사용하였다. 그저 바텀시트라는 컴포넌트를 띄우는 것이 목적이었기 때문이었다.
  • draggable이 아닌 anchoredDraggable을 사용하였다.
    • draggable을 사용할 경우 state에 DraggableState의 draggableState 변수를 넣어줘야 하는데 이는 internal로 작성이 되어 있어 접근이 불가능했다.
    • 반면 anchoredDraggable은 내가 SheetState 클래스에 정의한anchoredDraggableState 만으로도 처리가 가능했기 때문이다.
  • Surface의 modalBottomSheetAnchors의 경우, Material에서 사용하는 드래그 앵커와 디자인시스템에서 사용할 앵커가 다르기 때문에 커스텀해서 적용해주었다. 
    • 커스텀한 함수의 코드는 아래와 같은데, 각 변수에 대한 상세설명은 아래에서 확인하면 좋을 것 같다.
@OptIn(ExperimentalFoundationApi::class)
internal fun Modifier.modalBottomSheetAnchors( 
    sheetState: SheetState,
    fullHeight: Float
) = onSizeChanged { sheetSize ->

    val newAnchors = DraggableAnchors {
        Hidden at fullHeight - sheetSize.height
        Expanded at 0f
    }

    val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
        Hidden -> Hidden
        Expanded -> Expanded
    }

    sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
  • 추가로, Material과 상관 없이 디자인시스템 상에서는 최소 높이가 지정되어 있었기 때문에 Surface의 Column 안에 Column을 두 개로 두었다.
Column(
    // 생략.. 
) {
    Column(
        modifier = Modifier
            .defaultMinSize(minHeight = contentMinHeight), // 최소 사이즈 적용!
    ) {
        DragHandle()
        Spacer(modifier = Modifier.height(contentPadding))
        content()
        Spacer(modifier = Modifier.height(contentPadding))
    }
    Column { 
    	// 생략..
            OneButtonBottomSheet(
                buttonText = bottomSheetType.buttonText,
                onClick = onOneButtonClick
            )
        }
    }
}

하위에 있는 첫 번째 Column의 Modifier에 defaultMinSize가 적용되어 있는 걸 볼 수 있을 것이다.

이것이 컴포넌트의 최소 높이를 보장해주는 속성인데, 상위 Column에 적용하지 못한 이유는 상위에 적용을 해버리면 하단의 버튼의 영역까지 고려해서 최소 높이가 적용되기 때문이다.

이해하기 쉽게 위의 사진을 가져왔다.

이 사진은 상위에 defaultMinSize 속성을 적용했을 때의 모습이다.

 

SheetValue

이제부터 바텀시트의 속성을 정의하는 세부 내용에 대해서 설명할 것이다.

먼저, SheetValue이다.

Material의 SheetValue

Material은 상태가 총 3가지이다.

  • Hidden: 바텀시트가 보이지 않을 때
  • Expanded: 바텀시트가 최대 높이로 보일 때
  • PartiallyExpanded: 바텀시트가 부분적으로 보일 때

부분적으로 보이는 상태라면, 네이버 지도의 바텀시트를 떠올리면 이해하기 쉽다. 바텀시트가 Expanded 상태에서 일정 길이만큼 내렸을 때 PartiallyExpanded가 되고, 거기서 더 내리면 Hidden 상태가 되는 것이다. (물론, 이는 커스텀하기에 따라 다르다.)

내가 만든 SheetValue

이번 디자인시스템에서 바텀시트는 총 2가지 상태만 있다.

  • Hidden: 바텀시트가 보이지 않을 때
  • Expanded: 바텀시트가 최대 높이로 보일 때

즉, 부분적으로 보이는 상태가 없는 것이다. 이는 디자인 파트와 논의했을 때 부분적으로 보이는 기능은 1차 컴포넌트 개발 시 고려사항이 아니라고 판단하였기에 제외하였다. 

그래서 별도로 확장 및 축소 기능은 넣지 않았다.

 

rememberSheetState

바텀시트의 상태를 나타내는 SheetState를 생성하고 관리하는 함수이다.

Material의 rememberSheetState 함수

우선 MaterialrememberModalBottomSheetState를 통해 이를 호출하면 SheetState 객체가 생성되게 했다.

그리고 위 사진에 보이는 rememberSheetState를 통하여 내부적으로 상태를 관리하고 있다.

보이다시피, internal이기 때문에 외부에서 접근은 불가능하다.

 

중요한 포인트는, rememberSaveable을 통해서 SheetState의 Saver 함수를 호출하여 상태가 저장되고 유지되게 해준다는 점이다.

 

내가 만든 rememberSheetState 함수

그래서 나는 흐름은 똑같이 가져가되, 불필요한 변수들은 삭제하고 밀도를 측정하는 density만 저장하게 하였다.

 

SheetState

마지막으로 가장 중요한 SheetState 클래스에 대해서 설명하겠다.

Material의 SheetState

Material의 SheetState는 매우 복잡하다. 위 사진은 상단의 일부분만 가져온 것이다.

그래서 이를 전부 설명하는 대신, 내가 적용한 부분들만 소개하도록 하겠다. (흐름은 Material과 매우 흡사하다.)

내가 만든 SheetState

내가 적용한 SheetState 코드이다.

SheetState의 경우, 전체적으로 AnchoredDraggableState를 통해 내부적으로 관리되고 있다.

  • currentValue: 아래에서 정의한 anchoredDraggableState의 현재 값을 의미한다. (Hidden 또는 Expanded)
  • targetValue: 애니메이션을 도중 목표로 하는 상태를 의미한다.
  • isVisible: 바텀시트의 가시여부를 담당한다. (Hidden이 아닐 때로 설정한 이유는 추후 PartiallyExpanded가 도입될 경우를 고려하였다.)
  • requireOffset: 바텀시트의 y축(즉, 높이)을 반환한다.
  • hasExpandedState: 바텀시트가 확장 가능한 상태인지를 나타낸다. 이는 BottomSheet 함수 안에서 사용되며 hasExpandedState가 true일 경우, show() 함수를 호출한다.
  • animateTo(): 이 함수는 show()와 hide()를 호출할 때 사용된다. AnchoredDraggableState의 animateTo() 함수를 사용해 바텀시트가 자연스럽게 올라오고, 내려가는 기능을 구현한다.
  • anchoredDraggableState: 바텀시트를 드래그할 때 이동가능 범위를 나타낸다. 기본값은 Material과 동일하게 가져갔다. 아래 사진을 통해 일정 구간만큼 내리면 다음 상태(Hidden)로 전환되어 바탕 색깔도 함께 바뀌는 것을 확인할 수 있다.

일정 구간만큼 내리면 상태 전환 기능 구현

 

디자인시스템, Handy

지금까지 바텀시트의 Material 동작 방식과 내가 적용한 부분을 비교하면서 살펴보았다. 복잡한 내용을 간결하게 전달하려다 보니 이해하기 어려울 수도 있을 것 같다. (설명하지 못한 부분들도 많다..ㅠㅠ)

 

디자인시스템인 Handy 깃허브 레포를 첨부하면서 끝내겠다.

전체 코드가 궁금하다면 레포에서 확인해보면 좋을 것 같다.

https://github.com/yourssu/Handy-Android/pull/30

 

BottomSheet 구현 by leeeyubin · Pull Request #30 · yourssu/Handy-Android

Summary 바텀시트 동작 구현 방식은 최대한 머테리얼과 비슷하게 가져갔습니다! 바탕을 눌러도 바텀시트가 닫히고, 아래로 드래그 시에도 바텀시트가 닫힙니다. 위로 확장 드래그는 불가능해요. D

github.com