KINTO Tech Blog
Android

When We Put Compose on Top of BottomSheetDialogFragment, Anchoring a Button to the Bottom Proved Harder Than Expected

Cover Image for When We Put Compose on Top of BottomSheetDialogFragment, Anchoring a Button to the Bottom Proved Harder Than Expected

This article is part of day 6 of KINTO Technologies Advent Calendar 2024. 🎅🎄


Introduction

Merry Christmas 🔔. I am Romie (@Romie_ktc) from Osaka Tech Lab, where I work on Android-side development for the my route app in the Mobile App Development Group. In the my route Android team, we are currently switching the UI implementation from XML over to Jetpack Compose (hereinafter, Compose). However, since we cannot refactor everything all in one go, there will inevitably be situations where parts converted to Compose will be layered on top of XML. In this article, how we implemented it by overlaying Compose on top of the BottomSheet XML.

What the finished result looks like What the finished result looks like

Basics

The implementation is done with the following classes, which inherit BottomSheetDialogFragment.

class MixComposedBottomSheetDialog : BottomSheetDialogFragment()

Set the Basic Behavior of the BottomSheet.

Here, we set the behavior of the BottomSheet. The following code is put in onCreateView.

dialog?.setOnShowListener { dialogInterface ->
    val bottomSheetDialog = dialogInterface as BottomSheetDialog
    val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
    // If you want to set rounded corners and a background color, follow the steps below
    context?.let { bottomSheet?.background = ContextCompat.getDrawable(it, R.drawable.background_map_bottom_sheet) }
    val bottomSheetBehavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
    bottomSheetBehavior?.let { behavior ->
        // Set maxHeight and peekHeight to whatever heights you want.
        behavior.maxHeight = EXPANDED_HEIGHT // Set the height for when the BottomSheet is expanded as far as it will go
        behavior.peekHeight = COLLAPSED_HEIGHT // Set the height for when the BottomSheet is displayed in its initial state
        behavior.isHideable = false
        behavior.isDraggable = true
    }
}

Compose

By returning ComposeView via onCreateView, you can put Compose on top of BottomSheetDialogFragment.

return ComposeView(requireContext()).apply {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        BottomSheetContents()
    }
}

If this is all you want to do, then it is easy enough to understand. However, if you want to add a button that will always be at the bottom of the bottom sheet, things get trickier.

Developing Things Further

The button itself is implemented with Compose. However, adding a button in this way means it will not be displayed unless you scroll through the contents.

return ComposeView(requireContext()).apply {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        BottomSheetContents()
        ButtonOnBottomSheet()
    }
}

To ensure that the button will always be anchored to the bottom of the bottom sheet and will not get pulled about even by scrolling through the contents, you need to implement something like the following:

val button =
    ComposeView(context ?: return@setOnShowListener).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent {
            ButtonOnBottomSheet()
        }
    }

To anchor the button to the bottom of the bottom sheet, use the following code. Using this code enables you to directly retrieve the layout implemented with BottomSheetDialogFragment. Consequently, it enables you to manipulate views more flexibly.

val containerLayout = dialogInterface.findViewById<FrameLayout>(com.google.android.material.R.id.container)
val coordinatorLayout = dialogInterface.findViewById<CoordinatorLayout>(com.google.android.material.R.id.coordinator)

clipChildren is a property of ViewGroup that specifies whether to clip the drawing of a child view if it will be drawn outside the boundary of the parent view. It will be used when something overlaps other elements of the bottom sheet.

// The default value is true, and setting it to false lets you display child views as is even if they go outside the boundary of the parent.
button.clipChildren = false

button.layoutParams =
    FrameLayout.LayoutParams(
        FrameLayout.LayoutParams.MATCH_PARENT,
        FrameLayout.LayoutParams.WRAP_CONTENT,
    ).apply {
        gravity = Gravity.BOTTOM
    }
containerLayout?.addView(button)

button.post {
    val layoutParams = coordinatorLayout?.layoutParams as? ViewGroup.MarginLayoutParams
    layoutParams?.apply {
        button.measure(
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
        )
        this.bottomMargin = button.measuredHeight
        containerLayout?.requestLayout()
    }
}

Summary

Summarizing the implementation so far, we have the following:

override fun onCreateView(): View {
    dialog?.setOnShowListener { dialogInterface ->
        val bottomSheetDialog = dialogInterface as BottomSheetDialog

        val containerLayout = dialogInterface.findViewById<FrameLayout>(com.google.android.material.R.id.container)
        val coordinatorLayout = dialogInterface.findViewById<CoordinatorLayout>(com.google.android.material.R.id.coordinator)
        val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
        context?.let { bottomSheet?.background = ContextCompat.getDrawable(it, R.drawable.background_map_bottom_sheet) }

        val button =
            ComposeView(context ?: return@setOnShowListener).apply {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
                setContent {
                    ButtonOnBottomSheet()
                }
            }
        button.clipChildren = false

        button.layoutParams =
            FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.MATCH_PARENT,
                FrameLayout.LayoutParams.WRAP_CONTENT,
            ).apply {
                gravity = Gravity.BOTTOM
            }
        containerLayout?.addView(button)

        button.post {
            val layoutParams = coordinatorLayout?.layoutParams as? ViewGroup.MarginLayoutParams
            layoutParams?.apply {
                button.measure(
                    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
                    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
                )
                this.bottomMargin = button.measuredHeight
                containerLayout?.requestLayout()
                val bottomSheetBehavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
                bottomSheetBehavior?.let { behavior ->
                    // Set maxHeight and peekHeight to whatever heights you want.
                    behavior.maxHeight = EXPANDED_HEIGHT // Set the height for when the BottomSheet is expanded as far as it will go
                    behavior.peekHeight = COLLAPSED_HEIGHT // Set the height for when the bottom sheet is displayed in its initial state
                    behavior.isHideable = false
                    behavior.isDraggable = true
                }
            }
        }
    }

    return ComposeView(requireContext()).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent {
            BottomSheetContents()
        }
    }
}

In other words, here's how we implemented it:

  1. Create a BottomSheet in xml.
  2. Adjust the layout (i.e., the heights and so on).
  3. In order to attach the button to the bottom of the BottomSheet, prepare some more layout details and combine everything together.
  4. It means that we overlaid the Compose content onto the BottomSheet.

Thank you for reading all the way to the end!

Facebook

関連記事 | Related Posts

We are hiring!

【プロダクト開発バックエンドエンジニア(リーダークラス)】共通サービス開発G/東京・大阪

共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。

【フロントエンドエンジニア(コンテンツ開発)】新車サブスク開発G/大阪・福岡

新車サブスク開発グループについてTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』のWebサイトの開発、運用をしています。​業務内容トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、自社サービスである、クルマのサブスクリプションサービス『KINTO ONE』のWebサイトコンテンツの開発・運用業務を担っていただきます。

イベント情報

Cloud Security Night #2
製造業でも生成AI活用したい!名古屋LLM MeetUp#6
Mobility Night #3 - マップビジュアライゼーション -