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


# ExpandingBottomSheetとは
以下のように画面右下に配置されたコンポーネントがタップによって拡大し、別の画面を構成するようなBottomSheetです。
https://material.io/components/sheets-bottom#expanding-bottom-sheet
![Peek 2020-05-10 19-38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/d6440be2-b784-2bd4-1c07-7c388577ace8.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
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/5591c54a-2fa8-a8ce-90f0-e92cffa29880.gif width=400>


# ライブラリの追加
Material ComponentとCore KTXを利用します。

```gradle: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をネストすると描画が遅くなるので、プロダクトのコードでなどではネストせず記述するほうがよいでしょう。

```xml: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>
```

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

```xml: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の状態が徐々に変化するようにします。
全体のコードは以下の通りです。

```kotlin: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を追加してます)

```kotlin
binding.sheetLayout.doOnLayout {
    initSheetBehavior(binding)
}
```

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

```kotlin
// 初期位置の描画
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が入れ替わるようにします。

```kotlin
// 挙動に応じて位置や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) {
    }
})
```

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

```log
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も動的に変化させることで、レイアウトの入れ替わりを表現しています。

```kotlin
binding.sheetCollapsed.setOnClickListener {
    behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
```
最後にCollapsed状態のレイアウトにリスナーをセットし、タップしたらExpanded状態になるようにしています。逆にタップしたら閉じるボタンを用意したりと好みで変更すると良いと思います。

# 呼び出して利用する
Activityなどでfragmentを呼び出して完成です。

```xml: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>
```