【Android】BottomSheetDialogFragment で Jetpack Compose を活用する
こんにちは!アルダグラムでエンジニアをしている渡邊です!
弊社で提供しているモバイルアプリ「KANNA」は、主に React Native で開発されていますが、一部の機能や画面では Kotlin と Jetpack Compose を積極的に採用しています。このようなハイブリッドな構成において、BottomSheetDialogFragment
は React Native から Jetpack Compose で構築した画面や機能をシームレスに呼び出し、表示することが可能になります。
今回は、Android 開発で BottomSheetDialogFragment
を使用して Jetpack Compose の画面を表示する際のテクニック、特に全画面表示やナビゲーションの制御といった、より快適なユーザー体験を実現するための具体的な方法について詳しくご紹介します。
この記事では、以下の3つの主要なポイントに焦点を当てて解説していきます。
-
BottomSheetDialogFragment
を全画面で表示し、ユーザーによるスワイプ操作を無効にする方法 -
BottomSheetDialogFragment
内で Jetpack Compose のUIを正しく表示するための基本的な設定 -
navigation-compose
を利用する際のバックキーの挙動を適切に制御し、意図した通りの画面遷移を実現する方法
BottomSheetDialogFragment を全画面で表示し、スワイプを無効にする方法
BottomSheetDialogFragment
はデフォルトでは画面下部からコンテンツに応じた高さで表示され、ユーザーはスワイプダウンで簡単に閉じることができます。しかし、特定の画面では全画面で表示し、意図しないスワイプ操作で閉じられるのを防ぎたいケースがありますよね。
ここでは、そのようなカスタマイズを実現するために、BottomSheetDialogFragment
を継承したベースクラス ComposeDialogFragment
を作成し、その表示挙動を調整する方法を見ていきましょう。
以下のコードは、Stack Overflow で紹介されている情報を参考に、ボトムシートを全画面で表示し、かつスワイプ操作を無効にする実装です。
abstract class ComposeDialogFragment : BottomSheetDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = BottomSheetDialog(requireContext(), theme)
fun setupFullHeight(bottomSheet: View) {
val layoutParams = bottomSheet.layoutParams
layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT
bottomSheet.layoutParams = layoutParams
}
// ボトムシートの初期表示をフルスクリーンにする.
// 参考: https://stackoverflow.com/a/62958074
dialog.setOnShowListener {
val bottomSheet: View = dialog.findViewById(
com.google.android.material.R.id.design_bottom_sheet
) ?: return@setOnShowListener
val behavior = BottomSheetBehavior.from(bottomSheet)
setupFullHeight(bottomSheet)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
// ボトムシートのスワイプ操作を無効にする.
// 参考: https://stackoverflow.com/a/35794743
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_DRAGGING) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
})
}
return dialog
}
}
このコードの主なポイントは以下の通りです。
-
dialog.setOnShowListener
内で、ボトムシートのView
を取得する -
setupFullHeight
関数でレイアウトパラメータをWindowManager.LayoutParams.MATCH_PARENT
に設定し、高さを画面全体に広げる -
behavior.state = BottomSheetBehavior.STATE_EXPANDED
とすることで、ボトムシートを初期状態から展開された(全画面)状態にする -
behavior.addBottomSheetCallback
を使ってボトムシートの状態変化を監視する。onStateChanged
メソッド内で、もし状態がBottomSheetBehavior.STATE_DRAGGING
(ユーザーがドラッグしようとしている状態)になった場合、即座にbehavior.state = BottomSheetBehavior.STATE_EXPANDED
とすることで、スワイプによるクローズ操作を実質的に無効化する
この実装により、BottomSheetDialogFragment
を常に全画面で表示し、ユーザーが意図せず閉じてしまうのを防ぐことができます。
BottomSheetDialogFragment で Compose を表示するための設定
次に、作成した ComposeDialogFragment
内で Jetpack Compose の UI を表示するための基本的な設定方法について説明します。
Fragment 内で Compose の UI を表示するには、onCreateView
メソッドをオーバーライドし、ComposeView
のインスタンスを生成して返す必要があります。この際、特に重要なのが setViewCompositionStrategy
メソッドの設定です。
公式ドキュメント (Compose in Fragments | Android Developers) でも推奨されているように、ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
を設定することがベストプラクティスです。これにより、Fragment のビューのライフサイクルに合わせて Compose のコンポジションが適切に破棄されるようになり、メモリリークといった潜在的な問題を未然に防ぐことができます。
abstract class ComposeDialogFragment : BottomSheetDialogFragment() {
// onCreateDialog は前のセクションで定義済み
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { /* ... */ }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
// Fragment の View のライフサイクルに合わせて Composition を破棄する
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme { // AppTheme はプロジェクトで使用するテーマ
Surface(modifier = Modifier.fillMaxSize()) {
this@ComposeDialogFragment.Content()
}
}
}
}
}
@Composable
abstract fun Content()
}
上記のコードでは、onCreateView
で ComposeView
を生成し、setViewCompositionStrategy
を設定しています。そして setContent
ブロック内で、AppTheme
(ご自身のアプリのテーマに置き換えてください) と Surface
を使用して、実際の Compose UI を描画する準備をしています。
Content()
メソッドは抽象メソッドとして定義されており、この ComposeDialogFragment
を継承する具体的な Fragment クラスで、表示したい Compose UI を自由に実装することができます。
navigation-compose を使う場合のバックキーの制御方法
BottomSheetDialogFragment
内で navigation-compose
を使って複数の画面間を遷移する機能を実装する場合、一つ注意すべき点があります。それは、デバイスのバックキーを押した際のデフォルトの挙動です。通常、バックキーを押すと navigation-compose
の内部的な画面スタックを一つ戻るのではなく、BottomSheetDialogFragment
自体が閉じられてしまいます。これは、BottomSheetDialog
(より正確にはそのスーパークラスである ComponentDialog
)の onBackPressed()
が優先して呼び出されるためです。
この挙動を回避し、navigation-compose
の画面スタックを期待通りに操作するためには、バックキーが押された際の処理をカスタマイズする必要があります。具体的には、まず navigation-compose
のナビゲーション履歴を確認し、スタック内に戻れる画面が存在する場合はそちらを優先して戻り、これ以上戻れない場合にのみ Fragment を閉じる、という制御を行います。
この制御を実現するために、以下のような方針で ComposeDialogFragmentNavHost
というラッパーコンポーザブル関数を作成します。
-
NavHost
コンポーザブルをラップする - Jetpack Compose の
BackHandler
コンポーザブルを使用して、システムバックキーのイベントを捕捉する -
CompositionLocalProvider
を使用してLocalOnBackPressedDispatcherOwner
を現在のdialog
(BottomSheetDialog
のインスタンス)に上書きする。これにより、バックキーイベントのハンドリングがダイアログ側に移譲される -
navController.currentBackStack
を監視し、ナビゲーションスタック内に戻るべき画面が1つより多く存在するかどうかを判定する(canGoBack
) -
canGoBack
がtrue
の場合はnavController.popBackStack()
を呼び出してnavigation-compose
内で一つ前の画面に戻す -
canGoBack
がfalse
の場合(つまり、ナビゲーションスタックの最初の画面にいる場合)はBackHandler
が無効になり、結果としてBottomSheetDialog
のデフォルトのバックキー処理が実行され、Fragment 自体が閉じられることになる
abstract class ComposeDialogFragment : BottomSheetDialogFragment() {
// onCreateDialog と onCreateView は前のセクションで定義済み
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { /* ... (前述のコード) ... */ }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View { /* ... (前述のコード) ... */ }
@Composable
abstract fun Content()
@Composable
protected fun ComposeDialogFragmentNavHost(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
route: KClass<*>? = null,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) = { fadeIn(animationSpec = tween(700)) },
exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) = { fadeOut(animationSpec = tween(700)) },
popEnterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) = enterTransition,
popExitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) = exitTransition,
sizeTransform: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> SizeTransform?)? = null,
builder: NavGraphBuilder.() -> Unit,
) {
// LocalOnBackPressedDispatcherOwner を現在のダイアログ (BottomSheetDialog) に設定
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides (dialog as BottomSheetDialog)) {
// ナビゲーションスタックに戻るべき画面があるかどうかを監視
val canGoBack by produceState(initialValue = false, key1 = navController) {
@SuppressLint("RestrictedApi") // currentBackStack の使用に必要
navController.currentBackStack
.map { backStack ->
backStack.count { it.destination !is NavGraph } > 1
}
.collect { value = it }
}
BackHandler(enabled = canGoBack) {
navController.popBackStack()
}
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
contentAlignment = contentAlignment,
route = route,
typeMap = typeMap,
enterTransition = enterTransition,
exitTransition = exitTransition,
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
sizeTransform = sizeTransform,
builder = builder,
)
}
}
}
この ComposeDialogFragmentNavHost
を使った具体的な実装例を見てみましょう。
ComposeDialogFragment
を継承した SampleDialogFragment
を作成し、Content
メソッド内で ComposeDialogFragmentNavHost
を呼び出して画面遷移を定義します。
// 画面遷移先を定義
@Serializable
object FirstScreen
@Serializable
object SecondScreen
@Composable
private fun Screen(
title: String,
onBackButtonClicked: () -> Unit,
buttonContent: @Composable () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
@OptIn(ExperimentalMaterial3Api::class)
TopAppBar(
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onBackButtonClicked) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
) {
buttonContent()
}
}
}
class SampleDialogFragment : ComposeDialogFragment() {
@Composable
override fun Content() {
// この Dispatcher は Fragment 自体を閉じるために使用します (NavHost の外側のバック処理)
// 例えば、TopAppBar の戻るボタンが押されたときに Fragment を閉じる場合など。
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val navController = rememberNavController()
ComposeDialogFragmentNavHost(
navController = navController,
startDestination = FirstScreen
) {
composable<FirstScreen> {
Screen(
title = "First Screen",
onBackButtonClicked = { onBackPressedDispatcher?.onBackPressed() },
) {
Button(onClick = { navController.navigate(SecondScreen) }) {
Text(text = "Go to Second Screen")
}
}
}
composable<SecondScreen> {
Screen(
title = "Second Screen",
onBackButtonClicked = { onBackPressedDispatcher?.onBackPressed() },
) {
}
}
}
}
}
このサンプルコードでは、FirstScreen
から SecondScreen
へと遷移する簡単なナビゲーションを定義しています。ComposeDialogFragmentNavHost
によって、SecondScreen
でデバイスのバックキーを押すと FirstScreen
に戻り、FirstScreen
でバックキーを押すと SampleDialogFragment
自体が閉じる、という期待通りの挙動になります。
各画面の Screen
コンポーザブルに渡している onBackButtonClicked
は、主に TopAppBar
のナビゲーションアイコン(戻る矢印)が押された際に Fragment 自体を閉じるための処理です。これは ComposeDialogFragmentNavHost
によるシステムのバックキー制御とは独立して動作します。
最後に
今回は、BottomSheetDialogFragment
を使って Jetpack Compose の画面をより柔軟に、そしてユーザーにとって快適に表示するためのいくつかの重要なテクニックをご紹介しました。
これらの方法を活用することで、特に React Native のような既存のフレームワークとネイティブの Jetpack Compose を組み合わせるハイブリッドアプリ開発において、よりシームレスでユーザーフレンドリーな UI / UX を提供できるようになると思います。
この記事が、皆さんの Android アプリ開発の一助となれば幸いです!
もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion