Почти каждый андройд разработчик сталкивался с 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
}
}
}
}
})
}
}
}
Заключение
Данное решение не претендует на истинно верное.
Вероятно, есть более красивые способы сделать это, я буду рад узнать о таких!
Вы можете экспериментировать с размерами, константами и тд, чтобы подгонять этот подход под вашу ситуацию.
Спасибо за внимание! Буду рад замечаниям и предложениями.