BottomSheetDialogFragment с анимацией при смене состояния и sticky button

от автора

Почти каждый андройд разработчик сталкивался с 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *