📱

【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()
}

上記のコードでは、onCreateViewComposeView を生成し、setViewCompositionStrategy を設定しています。そして setContent ブロック内で、AppTheme (ご自身のアプリのテーマに置き換えてください) と Surface を使用して、実際の Compose UI を描画する準備をしています。

Content() メソッドは抽象メソッドとして定義されており、この ComposeDialogFragment を継承する具体的な Fragment クラスで、表示したい Compose UI を自由に実装することができます。

BottomSheetDialogFragment 内で navigation-compose を使って複数の画面間を遷移する機能を実装する場合、一つ注意すべき点があります。それは、デバイスのバックキーを押した際のデフォルトの挙動です。通常、バックキーを押すと navigation-compose の内部的な画面スタックを一つ戻るのではなく、BottomSheetDialogFragment 自体が閉じられてしまいます。これは、BottomSheetDialog(より正確にはそのスーパークラスである ComponentDialog)の onBackPressed() が優先して呼び出されるためです。

この挙動を回避し、navigation-compose の画面スタックを期待通りに操作するためには、バックキーが押された際の処理をカスタマイズする必要があります。具体的には、まず navigation-compose のナビゲーション履歴を確認し、スタック内に戻れる画面が存在する場合はそちらを優先して戻り、これ以上戻れない場合にのみ Fragment を閉じる、という制御を行います。

この制御を実現するために、以下のような方針で ComposeDialogFragmentNavHost というラッパーコンポーザブル関数を作成します。

  • NavHost コンポーザブルをラップする
  • Jetpack Compose の BackHandler コンポーザブルを使用して、システムバックキーのイベントを捕捉する
  • CompositionLocalProvider を使用して LocalOnBackPressedDispatcherOwner を現在の dialogBottomSheetDialog のインスタンス)に上書きする。これにより、バックキーイベントのハンドリングがダイアログ側に移譲される
  • navController.currentBackStack を監視し、ナビゲーションスタック内に戻るべき画面が1つより多く存在するかどうかを判定する(canGoBack
  • canGoBacktrue の場合は navController.popBackStack() を呼び出して navigation-compose 内で一つ前の画面に戻す
  • canGoBackfalse の場合(つまり、ナビゲーションスタックの最初の画面にいる場合)は 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 アプリ開発の一助となれば幸いです!


もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

GitHubで編集を提案
アルダグラム Tech Blog

Discussion