Help us understand the problem. What is going on with this article?

ExpandingBottomSheetをBottomSheetBehaviorでシンプルに実装する

ExpandingBottomSheetとは

以下のように画面右下に配置されたコンポーネントがタップによって拡大し、別の画面を構成するようなBottomSheetです。
https://material.io/components/sheets-bottom#expanding-bottom-sheet
Peek 2020-05-10 19-38.gif

https://github.com/material-components/material-components-android/issues/994 で話されているようにデザインとしては用意されているものの、2020年5月現在、コンポーネントとしては提供されておらず、自前で実装する必要があります。

FlutterのサンプルであるSHRINEでも使われていますが、こちらでもコンポーネントとしては提供されていないようです。
https://github.com/material-components/material-components-flutter/blob/master/docs/components/expanding-bottom-sheet.md#expanding-bottom-sheet

この記事はこのコンポーネントをミニマムに実装するコードを紹介します。

より詳しくリッチなアニメーションなどを行いたい場合にはmaterial-componentsのサンプルアプリであるOwlにて、この機能が実装されているので、参考にすると良さそうです。
https://github.com/material-components/material-components-android-examples/tree/develop/Owl

完成イメージ

プロジェクト全体は以下で公開しています。
https://github.com/ntsk/ExpandingBottomSheetSample

ライブラリの追加

Material ComponentとCore KTXを利用します。

app/build.gradle
implementation "com.google.android.material:material:1.1.0"
implementation 'androidx.core:core-ktx:1.2.0'

導入方法は以下になります。
https://github.com/material-components/material-components-android/blob/master/docs/getting-started.md
https://developer.android.com/kotlin/ktx#core

レイアウトの作成

通常のBottomSheetを実装する要領でCoordinatorLayoutとBottomSheetBehaviorを利用してレイアウトを作成します。
この際、CollapsedとExpandedの状態のレイアウトを予め用意しておきます。
以下ではイメージしやすくするためレイアウトをネストしてCollapsedとExpandedでレイアウトを分けて記述していますが、ConstraintLayoutをネストすると描画が遅くなるので、プロダクトのコードでなどではネストせず記述するほうがよいでしょう。

fragment_expanding_bottom_sheet.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/sheet_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@drawable/shape_sheet_background"
            app:behavior_peekHeight="56dp"
            app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/sheet_collapsed"
                android:layout_width="wrap_content"
                android:layout_height="56dp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent">

                <ImageView
                    android:id="@+id/sheet_collapsed_icon"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="16dp"
                    android:src="@drawable/ic_photo_library"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintEnd_toStartOf="@id/sheet_collapsed_count_text"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

                <TextView
                    android:id="@+id/sheet_collapsed_count_text"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="8dp"
                    android:layout_marginEnd="8dp"
                    android:text="@string/dummy_items_count"
                    android:textColor="@color/white"
                    app:layout_constraintBottom_toBottomOf="@id/sheet_collapsed_icon"
                    app:layout_constraintEnd_toStartOf="@id/sheet_collapsed_image"
                    app:layout_constraintStart_toEndOf="@id/sheet_collapsed_icon"
                    app:layout_constraintTop_toTopOf="@id/sheet_collapsed_icon"
                    tools:text="10" />

                <ImageView
                    android:id="@+id/sheet_collapsed_image"
                    android:layout_width="48dp"
                    android:layout_height="48dp"
                    android:padding="8dp"
                    android:src="@color/white"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toEndOf="@id/sheet_collapsed_count_text"
                    app:layout_constraintTop_toTopOf="parent" />
            </androidx.constraintlayout.widget.ConstraintLayout>

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/sheet_expanded"
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent">

                <TextView
                    android:id="@+id/sheet_expanded_title"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="16dp"
                    android:text="@string/dummy_contents"
                    android:textColor="@color/white"
                    android:textSize="16sp"
                    android:textStyle="bold"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

            </androidx.constraintlayout.widget.ConstraintLayout>
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

必要に応じて角丸も用意してあげます。

shape_bottom_sheet_background.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle"
    android:tint="@color/colorAccent">
    <corners android:topLeftRadius="24dp" />
</shape>

Fragmentの作成

上記のレイアウトを利用するFragmentを作成します。
BottomSheetBehaviorでViewが上下にドラッグできるようになっているはずですが、さらにtranslationXを利用することで、画面右下から画面全体に対して拡がるような挙動を作ります。また、alpha値を動的に変化させることで、上記で作成したCollapsedとExpandedの状態が徐々に変化するようにします。
全体のコードは以下の通りです。

ExpandingBottomSheetFragment.kt
class ExpandingBottomSheetFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val binding = FragmentExpandingBottomSheetBinding.inflate(inflater, container, false)
        binding.sheetLayout.doOnLayout {
            initSheetBehavior(binding)
        }
        return binding.root
    }

    private fun initSheetBehavior(binding: FragmentExpandingBottomSheetBinding) {
        val behavior = BottomSheetBehavior.from(binding.sheetLayout)
        val maxTransitionX =
            (binding.sheetLayout.width - binding.sheetCollapsed.width).toFloat()

        when (behavior.state) {
            BottomSheetBehavior.STATE_EXPANDED -> {
                binding.sheetLayout.translationX = 0f
                binding.sheetCollapsed.alpha = 0f
                binding.sheetExpanded.alpha = 1f
            }
            BottomSheetBehavior.STATE_COLLAPSED -> {
                binding.sheetLayout.translationX = maxTransitionX
                binding.sheetCollapsed.alpha = 1f
                binding.sheetExpanded.alpha = 0f
            }
        }

        behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
            override fun onSlide(bottomSheet: View, slideOffset: Float) {
                val inverseOffset = 1.0f - slideOffset
                binding.sheetLayout.translationX = maxTransitionX * inverseOffset
                binding.sheetCollapsed.alpha = inverseOffset
                binding.sheetExpanded.alpha = slideOffset
            }

            override fun onStateChanged(bottomSheet: View, newState: Int) {
            }
        })

        binding.sheetCollapsed.setOnClickListener {
            behavior.state = BottomSheetBehavior.STATE_EXPANDED
        }
    }
}

initSheetBehaviorではレイアウトの初期位置と挙動に応じたcallbackをセットしています。
translationをレイアウト描画後に行うため、doOnLayout内で行います。(このためにktxを追加してます)

binding.sheetLayout.doOnLayout {
    initSheetBehavior(binding)
}

maxTransitionXはCollapsed状態の際のsheetLayoutの位置のX座標になります。
レイアウト全体からCollapsedのレイアウトの幅を引いたところまでtranslationXで移動させます。
また、状態に応じてレイアウトのalphaを設定しています。

// 初期位置の描画
val maxTransitionX =
    (binding.sheetLayout.width - binding.sheetCollapsed.width).toFloat()

when (behavior.state) {
    BottomSheetBehavior.STATE_EXPANDED -> {
        binding.sheetLayout.translationX = 0f
        binding.sheetCollapsed.alpha = 0f
        binding.sheetExpanded.alpha = 1f
    }
    BottomSheetBehavior.STATE_COLLAPSED -> {
        binding.sheetLayout.translationX = maxTransitionX
        binding.sheetCollapsed.alpha = 1f
        binding.sheetExpanded.alpha = 0f
    }
}

BottomSheetCallback::onSlide を利用して、右下から左上にスライドしながらViewが入れ替わるようにします。

// 挙動に応じて位置やalphaを変化させる
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
    override fun onSlide(bottomSheet: View, slideOffset: Float) {
        val inverseOffset = 1.0f - slideOffset
        binding.sheetLayout.translationX = maxTransitionX * inverseOffset
        binding.sheetCollapsed.alpha = inverseOffset
        binding.sheetExpanded.alpha = slideOffset
    }

    override fun onStateChanged(bottomSheet: View, newState: Int) {
    }
})

onSlideslideOffset ではスクロールに応じたy座標の値を0〜1で取得することができます。
例えば下から上にスクロールされた場合以下のような値を取得できます。

2020-05-10 20:58:44.361 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.01809325
2020-05-10 20:58:44.377 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.046624914
2020-05-10 20:58:44.412 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.07933194
2020-05-10 20:58:44.425 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.24495476
2020-05-10 20:58:44.441 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.48086292
2020-05-10 20:58:44.458 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.6624913
2020-05-10 20:58:44.474 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.782881
2020-05-10 20:58:44.491 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.8705637
2020-05-10 20:58:44.508 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.92484343
2020-05-10 20:58:44.524 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.960334
2020-05-10 20:58:44.540 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.98051494
2020-05-10 20:58:44.557 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.99164927
2020-05-10 20:58:44.574 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.9972164
2020-05-10 20:58:44.590 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 0.9993041
2020-05-10 20:58:44.607 23567-23567/com.ntsk.expandingbottomsheetsample D/onSlide: 1.0

この値を利用して、x座標の値をtranslationXを利用して変化させます。初期位置(maxTransitionX)に対して、slideOffsetの逆数を掛けることで右下から左上に拡がるような挙動を表現しています。
また、CollapsedとExpandedのレイアウトのalphaも動的に変化させることで、レイアウトの入れ替わりを表現しています。

binding.sheetCollapsed.setOnClickListener {
    behavior.state = BottomSheetBehavior.STATE_EXPANDED
}

最後にCollapsed状態のレイアウトにリスナーをセットし、タップしたらExpanded状態になるようにしています。逆にタップしたら閉じるボタンを用意したりと好みで変更すると良いと思います。

呼び出して利用する

Activityなどでfragmentを呼び出して完成です。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
        xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <fragment
        android:id="@+id/expanding_bottom_sheet_fragment"
        android:name="com.ntsk.expandingbottomsheetsample.ExpandingBottomSheetFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="?actionBarSize"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
ntsk
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away