Почти каждый андройд разработчик сталкивался с BottomSheetBehavior, но гораздо реже требуется не просто показать BottomSheet, а ещё и добавить анимации, либо пригвоздить какой-то из элементов при раскрытии.
Недавно я столкнулся с такой задачей, реализовал её и решил составить небольшой туториал, который может помочь сэкономить время.
На данной гифке показано, что мы увидим в конце туториала:

Что нужно знать:
-
Как создать проект и запустить его
-
Kotlin
-
Поверхностное понимание что такое BottomSheetDialogFragment и BottomSheetBehavior
-
Поверхностное понимание что такое LayoutParams
-
ViewBinding(можно обойтись без него и использовать обычные findViewById)
Первый этап
На первом этапе мы сделаем смену контента в BottomSheetDialogFragment, визуализировав это fading эффектом.
Создадим в папке drawable файл под именем bottom_sheet_background.xml, в котором опишем background для нашего фрагмента, выставив цвет фона и закруглённые углы
<?xml version="1.0" encoding="UTF-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" /> <padding android:bottom="0dip" android:left="0dip" android:right="0dip" android:top="0dip" /> <solid android:color="#ffffff" /> </shape>
Далее опишем стиль для нашего фрагмента в файле styles.xml в папке values
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="AppBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog"> <item name="bottomSheetStyle">@style/AppModalStyle</item> </style> <style name="AppModalStyle" parent="Widget.Design.BottomSheet.Modal"> <item name="android:background">@drawable/bottom_sheet_background</item> </style> </resources>
Создадим файл bottom_sheet_layout.xml с разметкой для нашего фрагмента, в комментариях в xml расписано назначение каждого элемента.
bottom_sheet_layout.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" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- В данном layout содержится верхняя часть, которая не будет изменяться при изменении состояния В данном кейсе его можно заменить на TextView с compound drawable, но я оставлю LinearLayout для наглядности--> <LinearLayout android:id="@+id/layout_top" android:layout_width="match_parent" android:layout_height="100dp" android:orientation="horizontal" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <ImageView android:id="@+id/simple_image" android:layout_width="48dp" android:layout_height="match_parent" android:importantForAccessibility="no" app:srcCompat="@drawable/ic_launcher_foreground" app:tint="#32CD32" /> <TextView android:id="@+id/simple_text" android:layout_width="match_parent" android:layout_height="48dp" android:layout_gravity="center" android:gravity="center" android:text="Text and image in linear layout" android:textSize="20sp" /> </LinearLayout> <!-- В данном layout содержится разметка для collapsed состояния фрагмента --> <LinearLayout android:id="@+id/layout_collapsed" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_top"> <TextView android:layout_width="match_parent" android:layout_height="30dp" android:gravity="center" android:textStyle="italic" android:text="Text about something" android:textSize="24sp" /> <TextView android:layout_width="match_parent" android:layout_height="100dp" android:gravity="center" android:text="Some element, for example RecyclerView" android:textSize="32sp" /> </LinearLayout> <!-- В данном layout содержится разметка для развёрнутого состояния фрагмента Изначально она находится в состоянии invisible и располагается под layout_top как и layout_collapsed--> <LinearLayout android:id="@+id/layout_expanded" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:visibility="invisible" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_top"> <LinearLayout android:layout_width="match_parent" android:layout_height="72dp" android:orientation="vertical"> <TextView android:layout_width="match_parent" android:layout_height="24dp" android:gravity="center" android:text="First Line" android:textSize="20sp" /> <TextView android:layout_width="match_parent" android:layout_height="24dp" android:gravity="center" android:text="Second Line" android:textSize="20sp" /> <TextView android:layout_width="match_parent" android:layout_height="24dp" android:gravity="center" android:text="Third Line" android:textSize="20sp" /> </LinearLayout> <TextView android:layout_width="match_parent" android:layout_height="30dp" android:layout_marginTop="16dp" android:gravity="center" android:text="Text about something" android:textSize="24sp" /> <TextView android:layout_width="match_parent" android:layout_height="180dp" android:gravity="center" android:text="Some bigger element, for example bigger RecyclerView" android:textSize="36sp" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
В разметке мы имеем одну ViewGroup, которая будет в обоих стейтах и не будет изменяться, и две отдельных ViewGroup для состояний collapsed и expanded.
При вытягивании фрагмента первая будет исчезать и в определённый момент будет заменена второй.
Мы можем перейти к созданию самого фрагмента.
Создадим класс BottomFragment, унаследовав его от BottomSheetDialogFragment
Полный код BottomFragment
private const val COLLAPSED_HEIGHT = 228 class BottomFragment : BottomSheetDialogFragment() { // Можно обойтись без биндинга и использовать findViewById lateinit var binding: BottomSheetLayoutBinding // Переопределим тему, чтобы использовать нашу с закруглёнными углами override fun getTheme() = R.style.AppBottomSheetDialogTheme override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = BottomSheetLayoutBinding.bind(inflater.inflate(R.layout.bottom_sheet_layout, container)) return binding.root } // Я выбрал этот метод ЖЦ, и считаю, что это удачное место // Вы можете попробовать производить эти действия не в этом методе ЖЦ, а например в onCreateDialog() override fun onStart() { super.onStart() // Плотность понадобится нам в дальнейшем val density = requireContext().resources.displayMetrics.density dialog?.let { // Находим сам bottomSheet и достаём из него Behaviour val bottomSheet = it.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout val behavior = BottomSheetBehavior.from(bottomSheet) // Выставляем высоту для состояния collapsed и выставляем состояние collapsed behavior.peekHeight = (COLLAPSED_HEIGHT * density).toInt() behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { // Нам не нужны действия по этому колбеку } override fun onSlide(bottomSheet: View, slideOffset: Float) { with(binding) { // Нас интересует только положительный оффсет, тк при отрицательном нас устроит стандартное поведение - скрытие фрагмента if (slideOffset > 0) { // Делаем "свёрнутый" layout более прозрачным layoutCollapsed.alpha = 1 - 2 * slideOffset // И в то же время делаем "расширенный layout" менее прозрачным layoutExpanded.alpha = slideOffset * slideOffset // Когда оффсет превышает половину, мы скрываем collapsed layout и делаем видимым expanded if (slideOffset > 0.5) { layoutCollapsed.visibility = View.GONE layoutExpanded.visibility = View.VISIBLE } // Если же оффсет меньше половины, а expanded layout всё ещё виден, то нужно скрывать его и показывать collapsed if (slideOffset < 0.5 && binding.layoutExpanded.visibility == View.VISIBLE) { layoutCollapsed.visibility = View.VISIBLE layoutExpanded.visibility = View.INVISIBLE } } } } }) } } }
Обратим внимание на ключевые моменты
-
Мы получаем BottomSheetBehavior, выставляем ему peekHeight и вешаем на него слушателя
-
В методе onSlide() в зависимости от оффсета мы меняем прозрачность наших двух layout и в определённый момент меняем их видимость
-
Коэффициент 0.5 можно заменить на какой-либо другой и тогда исчезание и появление будет происходить раньше или позже
Вызовем наш фрагмент. Чтобы сделать это быстрее и не добавлять новые кнопки и слушатели, сделаем это прямо из метода onCreate() в MainActivity
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) BottomFragment().show(supportFragmentManager, "tag") } }
Можем запускать наш проект. Мы увидим вот такое поведение:

Второй этап
На данном этапе мы добавим sticky кнопку внизу нашего фрагмента. Для этого мы программно добавим view к нашему экрану.
Сперва создадим layout файл button.xml с кнопкой
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#ffffff"> <Button android:id="@+id/button_a" android:layout_width="match_parent" android:layout_height="60dp" android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:backgroundTint="#32CD32" android:text="Sticky Button" /> </LinearLayout>
Далее программно добавим кнопку к нашему фрагменту, вставив этот код между установкой behavior.state и добавлением коллбека behavior.addBottomSheetCallback
// Достаём корневые лэйауты val coordinator = (it as BottomSheetDialog).findViewById<CoordinatorLayout>(com.google.android.material.R.id.coordinator) val containerLayout = it.findViewById<FrameLayout>(com.google.android.material.R.id.container) // Надуваем наш лэйаут с кнопкой val buttons = it.layoutInflater.inflate(R.layout.button, null) // Выставляем параметры для нашей кнопки buttons.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT ).apply { height = (60 * density).toInt() gravity = Gravity.BOTTOM } // Добавляем кнопку в контейнер containerLayout?.addView(buttons) // Перерисовываем лэйаут buttons.post { (coordinator?.layoutParams as ViewGroup.MarginLayoutParams).apply { buttons.measure( View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ) // Устраняем разрыв между кнопкой и скролящейся частью this.bottomMargin = (buttons.measuredHeight - 8 * density).toInt() containerLayout?.requestLayout() } }
Ключевые моменты:
-
Мы программно надуваем и добавляем наш layout с кнопкой к родительскому layout
-
Вместо кнопки мы можем начинить наш layout любыми другими view
Запустив проект мы увидим следующее:

Полный финальный код
private const val COLLAPSED_HEIGHT = 228 class BottomFragment : BottomSheetDialogFragment() { // Можно обойтись без биндинга и использовать findViewById lateinit var binding: BottomSheetLayoutBinding // Переопределим тему, чтобы использовать нашу с закруглёнными углами override fun getTheme() = R.style.AppBottomSheetDialogTheme override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = BottomSheetLayoutBinding.bind(inflater.inflate(R.layout.bottom_sheet_layout, container)) return binding.root } // Я выбрал этот метод ЖЦ, и считаю, что это удачное место // Вы можете попробовать производить эти действия не в этом методе ЖЦ, а например в onCreateDialog() override fun onStart() { super.onStart() // Плотность понадобится нам в дальнейшем val density = requireContext().resources.displayMetrics.density dialog?.let { // Находим сам bottomSheet и достаём из него Behaviour val bottomSheet = it.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout val behavior = BottomSheetBehavior.from(bottomSheet) // Выставляем высоту для состояния collapsed и выставляем состояние collapsed behavior.peekHeight = (COLLAPSED_HEIGHT * density).toInt() behavior.state = BottomSheetBehavior.STATE_COLLAPSED // Достаём корневые лэйауты val coordinator = (it as BottomSheetDialog).findViewById<CoordinatorLayout>(com.google.android.material.R.id.coordinator) val containerLayout = it.findViewById<FrameLayout>(com.google.android.material.R.id.container) // Надуваем наш лэйаут с кнопкой val buttons = it.layoutInflater.inflate(R.layout.button, null) // Выставояем параметры для нашей кнопки buttons.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT ).apply { height = (60 * density).toInt() gravity = Gravity.BOTTOM } // Добавляем кнопку в контейнер containerLayout?.addView(buttons) // Перерисовываем лэйаут buttons.post { (coordinator?.layoutParams as ViewGroup.MarginLayoutParams).apply { buttons.measure( View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ) // Устраняем разрыв между кнопкой и скролящейся частью this.bottomMargin = (buttons.measuredHeight - 8 * density).toInt() containerLayout?.requestLayout() } } behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { // Нам не нужны действия по этому колбеку } override fun onSlide(bottomSheet: View, slideOffset: Float) { with(binding) { // Нас интересует только положительный оффсет, тк при отрицательном нас устроит стандартное поведение - скрытие фрагмента if (slideOffset > 0) { // Делаем "свёрнутый" layout более прозрачным layoutCollapsed.alpha = 1 - 2 * slideOffset // И в то же время делаем "расширенный layout" менее прозрачным layoutExpanded.alpha = slideOffset * slideOffset // Когда оффсет превышает половину, мы скрываем collapsed layout и делаем видимым expanded if (slideOffset > 0.5) { layoutCollapsed.visibility = View.GONE layoutExpanded.visibility = View.VISIBLE } // Если же оффсет меньше половины, а expanded layout всё ещё виден, то нужно скрывать его и показывать collapsed if (slideOffset < 0.5 && binding.layoutExpanded.visibility == View.VISIBLE) { layoutCollapsed.visibility = View.VISIBLE layoutExpanded.visibility = View.INVISIBLE } } } } }) } } }
Заключение
Данное решение не претендует на истинно верное.
Вероятно, есть более красивые способы сделать это, я буду рад узнать о таких!
Вы можете экспериментировать с размерами, константами и тд, чтобы подгонять этот подход под вашу ситуацию.
Спасибо за внимание! Буду рад замечаниям и предложениями.
ссылка на оригинал статьи https://habr.com/ru/post/567828/
Добавить комментарий