Как стать автором
Обновить

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

Время на прочтение10 мин
Количество просмотров18K

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

Заключение

Полный код вот здесь

Данное решение не претендует на истинно верное.

Вероятно, есть более красивые способы сделать это, я буду рад узнать о таких!

Вы можете экспериментировать с размерами, константами и тд, чтобы подгонять этот подход под вашу ситуацию.

Спасибо за внимание! Буду рад замечаниям и предложениями.

Теги:
Хабы:
+5
Комментарии5

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата16 апреля
Время09:30 – 21:00
Место
МинскОнлайн
EvaConf 2024
Дата16 апреля
Время11:00 – 16:00
Место
МоскваОнлайн
Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн