由於 Standard 的特性是與主畫面共存與輔助,所以實作上,要透過 Coordinator 佈局來設置 bottom sheet,透過 app:layout_behavior
屬性將這個佈局設為一個持久的 Bottom Sheet,這是一個從屏幕底部升起的 View,位於主要內容之上。可以垂直拖動它以顯示更多或更少的內容。
基本上所有的 Bottom Sheet 都會是 Framelayout,因為圖層上要覆蓋原本的主畫面
<androidx.coordinatorlayout.widget.CoordinatorLayout
...>
<FrameLayout
...
android:id="@+id/standard_bottom_sheet"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<!-- Bottom sheet contents. -->
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Modal 則是不與主畫面共存,所以要透過 BottomSheetDialogFragment 來生成畫面,基本上與 Fragment 用法相似。在主畫面呼叫時,要先初始化並透過 show()
來顯示。這邊範例是在 Activity 去呼叫,如果是在 Fragment 的話,manager 要使用 childFragmentManager
class ModalBottomSheet : BottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.modal_bottom_sheet_content, container, false)
companion object {
const val TAG = "ModalBottomSheet"
}
}
class MainActivity : AppCompatActivity() {
...
val modalBottomSheet = ModalBottomSheet()
modalBottomSheet.show(supportFragmentManager, ModalBottomSheet.TAG)
...
}
由於官方沒有提供實作,這邊就用我學過得東西簡單實作一下。透過 Container transform 、navigation 還有 Fragment 的結合來實現,結果發現並沒有用到 BottomSheetDialogFragment。Material Deisgn 之所以沒特別介紹,是因為在實作上可以透過各種不同方法達成,甚至可以說它算是動畫實作的一環,而不算是 Bottom Sheet
layout 設置上的重點放在 transitionName 上面,透過它可以實現 Transform 動畫,也就是我們想要的讓被縮小的 Bottom sheet 放大成 Full-screen 的效果,如果對 Transform 不太理解,我剛好寫過文章可以先看看,或是觀看官方文檔
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat 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"
android:transitionName="@string/trans_expand_bottomsheet"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/fullscreen_bottomsheet_tb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/darkBlue"
android:paddingVertical="5dp"
app:navigationIcon="@drawable/ic_baseline_close_24"
app:title="Expanding Bottom Sheet">
</com.google.android.material.appbar.MaterialToolbar>
<!-- Bottom sheet Content-->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
...
...
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<com.google.android.material.button.MaterialButton
...
android:id="@+id/expand_bottom_sheet_btn"
android:text="Expanding Sheet"
android:transitionName="@string/trans_expand_bottomsheet"
...
/>
用 Fragment 就能實現我們要的功能,接著在裡面設置 shareElementEnterTransition 給予 MaterialContainerTransform 物件,就能讓系統為我們設置動畫,當中有許多參數可以調整,有興趣的可以看官方文檔更加仔細的介紹。記得要設置返回 Collapsed 狀態的操作,透過 navigateUp()
導回上個畫面
class ExpandingBottomSheetFragment :
BaseFragment<FragmentExpandingBottomSheetBinding>(
FragmentExpandingBottomSheetBinding::inflate
) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform().apply {
duration = 1000
fadeMode = MaterialContainerTransform.FADE_MODE_CROSS
}
}
private val navController by lazy { findNavController() }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.fullscreenBottomsheetTb.setNavigationOnClickListener {
navController.navigateUp()
}
}
}
用 navigation 實現 transform 動畫不能使用一般的導航方式,要透過 extras 物件綁定要執行動畫的 View 與對應的 transitionName,Name 的部分要與我們剛剛在 Expanding layout 的父層佈局所設置的相同,否則系統不會知道哪兩個元件要執行動畫,建議儲存為資源檔案以免遺失或輸入錯誤
binding.expandBottomSheetBtn.setOnClickListener {
val extras = FragmentNavigatorExtras(binding.expandBottomSheetBtn to getString(R.string.trans_expand_bottomsheet))
navController.navigate(R.id.action_standardBottomSheetsFragment_to_expandingBottomSheetFragment,null,null,extras)
}
在 Bottom sheet 中,有著相同可控制的屬性與更細部的設定,下面會切分成幾個部分來介紹
這邊介紹幾個方法,可以用來控制 standard 與 modal 在主畫面的交互行為
可以通過將它們設置在具有 app:layout_behavior
設置的同一個子視圖上來在 xml 中應用它們,或者在編程中去設置
in code
val standardBottomSheetBehavior = BottomSheetBehavior.from(standardBottomSheet)
也是有兩種方法可以設置,但不能透過剛剛上述的屬性 app:layout_behavior
,因為是獨立於主畫面的,所以就只能透過 style 與 theme 來下手
<style name="ModalBottomSheet" parent="Widget.MaterialComponents.BottomSheet.Modal">
<!-- Apply attributes here -->
</style>
<style name="ModalBottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
<item name="bottomSheetStyle">@style/ModalBottomSheet</item>
</style>
<style name="AppTheme" parent="Theme.MaterialComponents.*">
<item name="bottomSheetDialogTheme">@style/ModalBottomSheetDialog</item>
</style>
或是在頁面生成時透過編程設置
val modalBottomSheetBehavior = (modalBottomSheet.dialog as BottomSheetDialog).behavior
為了在配置更改時保存和恢復 BottomSheet 的特定行為,可設置以下標誌 :
SAVE_PEEK_HEIGHT
: 保留 behavior_peekHeight
該屬性
SAVE_FIT_TO_CONTENTS
:保留 behavior_fitToContents
該屬性
SAVE_HIDEABLE
:保留 behavior_hideable
該屬性
SAVE_SKIP_COLLAPSED
:保留 behavior_skipCollapsed
該屬性
SAVE_ALL
:將保留所有上述所有屬性
SAVE_NONE
:將不保留任何屬性,這是系統預設值
in code :
val bottomSheetBehavior = BottomSheetBehavior.from(binding.bottomNavigationView)
bottomSheetBehavior.saveFlags = BottomSheetBehavior.SAVE_ALL
in layout :
<FrameLayout
android:id="@+id/standard_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
**app:behavior_saveFlags="none"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
Standard 與 Modal 都是擁有相同的 State attributes
in code :
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
狀態改變時,可以透過 BottomSheetCallback 來監聽 Bottom Sheet 來響應用戶的各種行為
val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Do something for new state.
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
// Do something for slide offset.
}
}
// To add the callback:
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
// To remove the callback:
bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback)
BottomSheetBehavior 可以通過指定以下任何一項來自動處理 insets 間距:
app : paddingBottomSystemWindowInsets
app : paddingLeftSystemWindowInsets
app : paddingRightSystemWindowInsets
app : paddingTopSystemWindowInsets
在 API 21 及更高版本上,如果導航欄是透明的並且 app:enableEdgeToEdge 為 true,則 Modal bottom sheet 將全屏呈現
如果上述任何 padding attributes 在樣式中設置為 true,它可以自動添加插入,方法是更新傳遞給構造函數的 Style,或者更新主題中的 ?attr/bottomSheetDialogTheme 屬性指定的默認樣式
BottomSheetDialog 會在滑動到 status bar 下方時,添加 padding 以防繪製阻擋到 status bar
若是想控制 standard bottom sheet 收起的高度,可透過 app:behavior_peekHeight
若想完全收起 sheet,將 app:behavior_hideable="true"
自定義的部分,由於 Material Design 在預設上已經幫我們處理好,基本上只要改變形狀跟顏色就足以滿足許多情境。至於要改變像是動畫或一些行為的屬性,就要挖得比較深,也可看上方的屬性表來參考。但實際上也不建議去動,因為作者我已經體驗過,就像是進了精神時光屋,所以入坑前請三思
<style name="ModalBottomSheet" parent="Widget.MaterialComponents.BottomSheet.Modal">
<item name="backgroundTint">@color/darkGrey</item>
<item name="shapeAppearance">@style/ShapeAppearance.App.LargeComponent</item>
</style>
<style name="ShapeAppearance.App.LargeComponent" parent="ShapeAppearance.MaterialComponents.LargeComponent">
<item name="cornerFamily">cut</item>
<item name="cornerSize">24dp</item>
</style>
Bottom sheet 在實作上非常有彈性,甚至不需要用 BottomSheetDialogFragment 就能透過 Fragment 做出類似的效果。因為這些也只是 Material Design 幫我們包好的 API 與套件。建議在高度客製化的場景不要使用,因為它的很多屬性與細部的操控都要透過 theme 與 style,或是一些方法來實現,使用上要花比較多時間去閱讀文件或測試。而如果使用上是想做出簡單動畫或呈現的 Bottom sheet 就非常推薦,只要將想要的畫面做出來,剩下的 Material Design 幫我們實現,建議還沒玩過的大家歡迎試試 (入坑)
若對實作還是有點不懂的,這邊提供我的 Github 方便大家參考