ExpandingBottomSheetとは
以下のように画面右下に配置されたコンポーネントがタップによって拡大し、別の画面を構成するようなBottomSheetです。
https://material.io/components/sheets-bottom#expanding-bottom-sheet
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を利用します。
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 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 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の状態が徐々に変化するようにします。
全体のコードは以下の通りです。
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) {
}
})
onSlide
のslideOffset
ではスクロールに応じた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を呼び出して完成です。
<?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>