Hello! ModalBottomSheet

Compose material3에 추가된 ModalBottomSheet에 대해 알아보자.

Hello! ModalBottomSheet
material 3 bottom sheets components usage

23년 2월, Compose material3 1.1.0-alpha06 버전에서 ModalBottomSheet가 추가되었다🎉🚀

나는 Compose에서 BottomSheet를 구현하고자 할 때 겪을 수 있는 어려움과 이를 해결한 라이브러리 개발 과정에 대해 공유한 적이 있다.

Compose를 위한 BottomSheetDialog 라이브러리 개발기
이번 글에서는 많은 사람들이 문제를 겼었던 “Compose에서 BottomSheetDialog 구현”에 대해 이야기 해보고자 한다.

이번 글에서는 ModalBottomSheet 의 핵심 구현 및 구조와 나의 경험을 바탕으로 느낀 점을 간단히 공유해보려 한다.

핵심은 Window 분리

  • 한 화면에는 여러 개의 BottomSheetDialog가 올 수 있다.
  • Dialog Composable 처럼 쓰기 쉬워야 한다.

위 글에서 내 라이브러리가 달성하고자 했던 요구사항인데, ModalBottomSheet 또한 이 요구사항을 충족한다.

위 요구 사항을 달성하는데 있어 핵심적인 요소는 ‘Window 분리’였다. Compose에서 분리된 윈도우에 Composable을 그리는 요소는 Dialog, Popup 밖에 없는데 내가 착안한 것은, Dialog composable의 내부구현을 참고하는 것이었다. 어차피 Android의 ComponentDialog를 사용하고 있다보니 이것을 material-components-android의 BottomSheetDialog로 갈아끼자는 것이 95% 정도 핵심적인 아이디어였다.

// compose-ui 
// AndroidDialog.android.kt 
@Composable 
fun Dialog( 
  ... 
) { 
  ... 
  val dialog = remember(...) { 
    DialogWrapper( 
      ... 
    ) { 
      ... 
    } 
  } 
} 
 
private class DialogWrapper( 
  ... 
) : ComponentDialog( 
  ... 
) 
 
// bottomsheetdialog-compose 
// BottomSheetDialog.kt 
import com.google.android.material.bottomsheet.BottomSheetDialog 
 
@Composable 
fun BottomSheetDialog( 
  ... 
) { 
  ... 
  val dialog = remember(...) { 
    BottomSheetDialogWrapper( 
      ... 
    ) { 
      ... 
    } 
  } 
} 
 
private class BottomSheetDialogWrapper( 
  ... 
) : BottomSheetDialog( 
  ... 
)

ModalBottomSheetPopup

반면 compose material3에서는 Window 분리를 위해 앞서 언급한 Popup 을 사용하고 swipe등 bottom sheet의 behavior를 compose로 구현한다.

// commonMain - ModalBottomSheet.kt 
internal expect fun ModalBottomSheetPopup( 
    onDismissRequest: () -> Unit, 
    content: @Composable () -> Unit 
) 
 
// androidMain - ModalBottomSheetPopup.android.kt 
import androidx.compose.runtime.Composable 
import androidx.compose.ui.window.Popup 
import androidx.compose.ui.window.PopupProperties 
 
@Composable 
@ExperimentalMaterial3Api 
internal actual fun ModalBottomSheetPopup( 
    onDismissRequest: () -> Unit, 
    content: @Composable () -> Unit 
) = Popup( 
    onDismissRequest = onDismissRequest, 
    properties = PopupProperties(focusable = true), 
    content = content 
)

compose material3는 multiplatform을 target으로 하기에 공통 source인 commonMainModalBottomSheetPopupexpect 로 선언해두고, 안드로이드를 위한 sourceSet인 androidMain 에서 실제 구현 actual 을 추가해둔 모습이다.

Dialog 를 처음 써본 사람들이 당황하는 부분 중 하나가, Surface 와 같은 배경을 깔아주는 것은 온전히 개발자의 책임이기에 무언가 배경이 따로 그려지지 않는다는 것이다. 그런 측면에서 ModalBottomSheetPopup 는 오로지 window를 분리하기 위하여 사용되며 본격적인 디자인 스펙은 이것을 활용한 ModalBottomSheet 에서 구현된다.

ModalBottomSheet

ModalBottomSheet 의 주요 구현은 아래 2개 파일을 보면 확인할 수 있다.

// ModalBottomSheet.android.kt 
fun ModalBottomSheet( 
    onDismissRequest: () -> Unit, 
    modifier: Modifier = Modifier, 
    sheetState: SheetState = rememberModalBottomSheetState(), 
    shape: Shape = BottomSheetDefaults.ExpandedShape, 
    containerColor: Color = BottomSheetDefaults.ContainerColor, 
    contentColor: Color = contentColorFor(containerColor), 
    tonalElevation: Dp = BottomSheetDefaults.Elevation, 
    scrimColor: Color = BottomSheetDefaults.ScrimColor, 
    dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, 
    content: @Composable ColumnScope.() -> Unit, 
) { 
  ... 
}

먼저 ModalBottomSheet 는 위와 같은 인터페이스를 제공한다. Sheet의 theme 관련 인자를 커스터마이즈 할 수 있으며 SheetState 를 통해 sheet의 상태를 읽거나 변경할 수 있다.

// ModalBottomSheet.android.kt 
fun ModalBottomSheet( 
  ... 
) { 
  ... 
  ModalBottomSheetPopup( 
    ... 
  ) { 
    BoxWithConstraints(Modifier.fillMaxSize()) { 
      val fullHeight = constraints.maxHeight // swipe 관련 modifier에서 사용 
      ... 
    } 
  } 
}

앞서 소개한 것과 같이 내부에선 ModalBottomSheetPopup 을 사용하여 window를 분리한다. 그 아래 child로는 BoxWithConstraintsfillMaxSize modifier를 이용한다.

// ModalBottomSheetPopup 내부 
BoxWithConstraints(Modifier.fillMaxSize()) { 
  ... 
  // sheet가 떠있는 검은 배경, 클릭 시 dismiss 
  Scrim( 
    ... 
  ) 
  Surface( 
    // swipe관련 modifier, 
    // ModalBottomSheet에 넘겨준 sheet shape, color 적용 
    ... 
  ) { 
    Column(...) { 
      if (dragHandle != null) { 
        Box( 
          ... 
        ) { 
          dragHandle() // sheet 상단의 swipe 손잡이 
        } 
      } 
      content() // sheet의 본 내용 
    } 
  } 
  ... 
}

위 코드는 굉장히 간략화된 ModalBottomSheetcomposable의 내부 구조이다. 별다른 캡쳐를 첨부하지 않았지만, 다들 대략적으로 어떤 식으로 그려질 지 상상이 되실 거라 기대가 될만큼 구현이 간결하고 깔끔하다. 위와 같은 코드 해체/분석을 통해 분리된 window안에서 오로지 Compose만으로 요구사항을 어떻게 달성하는지 확인할 수 있었다.😎

마치며

Compose가 오픈 소스인 것은 축복이다!

compose로 개발을 한다면 재사용이 가능한 컴포넌트를 설계하고 구현하는 일이 많을 것이다. compose 초기에 참고할 지식이 많지 않아(구글링 등) compose 내부 코드를 보던 습관이 어느 정도 양질의 코드를 작성하는데 도움이 많이 되었다.

이번 글을 작성하면서 잠깐 component를 살펴본 시간 속에서도 배울 점들이 정말 많았던 것 같다. compose 코드랩을 마치고 좀 더 고차원적인 공부를 원하시는 사람이라면 다음 공부 목표로는 Compose 내부 구현 공부를 추천한다. 눈에 보이지 않는 것은 흥미가 떨어질 수 있으니 이번 글처럼 material3 component 구현 살펴보기 같은 것도 좋아보인다.

bottomsheetdialog-compose의 행방은?

처음으로 배포한 라이브러리인데 star도 점점 늘어나 어느덧 115개가 되었다.(23년 5월 기준) 이제 google에 bottomsheetdialog compose라고 검색하면 내 라이브러리가 나오고, 국내외 다양한 국적의 개발자들이 star와 issue를 남겨주었다.

오픈소스 라이브러리 배포를 해보면서 좋았던 점은

  1. 개발자들이 사용할 인터페이스에 대한 고민을 해본 경험
  2. 내가 느낀 문제를 다른 사람들도 겪었고, 해결책을 제시했다는 성취감

등이었다.

bottomsheetdialog-compose 라이브러리는

  • material3 의존성을 바라지 않는 분들을 위한 standalone 라이브러리 (👋)로 남게 될 것이다.
  • View/xml 기반의 material-components-android 라이브러리에 의존하여 발생했던 이슈도 있는 만큼, 내부 구현을 compose로 전환할 것이다.
  • 또한, compose multiplatform도 지원할 생각이다.

언제나 Contribution은 환영이니, 아이디어가 있으시다면 issue, pull request를 남겨주길 바란다.

Jetpack Compose 사용자 모임 | 홀릭스(HOLIX)
이미 Production에서 사용중인 개발자와 새로 공부하는 뉴비까지, 안드로이드의 새로운 UI 프레임워크에 대한 최신 정보를 얻으세요!

필자가 운영 중인 Jetpack Compose 커뮤니티