FloatingActionMode — панель контекстных действий для Android

Контекстные действия с элементами списка широко используются с Android-приложениях. Довольно удобно выделить несколько элементов или все элементы списка и применить какое-то действие ко всем выбранным элементам сразу. Удалить, например.


В Android-приложениях для этого может использоваться ActionMode, который позволяет отобразить доступные действия над выделенными элементами поверх Toolbar. Там же можно показывать пользователю сколько элементов выделено в текущий момент или другую полезную информацию. Это удобно и хорошо смотрится, но в некоторых случаях информация, отображаемая на самом Toolbar, может быть важна и скрывать ее не хотелось бы. К примеру, там может быть имя и фото пользователя, список сообщений с которым отображается в списке. При выделении некоторых сообщений полезно было бы видеть имя пользователя, которому эти сообщения адресованы.


В этом случае можно отображать панель контекстных действий с элементами списка поверх самого списка, не загораживая Toolbar. О создании такой панели контекстных действий я и расскажу в этой статье.


Разрабатываемый CustomView — панель контекстных действий я назвал FloatingActionMode или просто FAM.


Art
FloatingActionMode во время работы


Видео — пример работы с FloatingActionMode


XML-атрибуты


Для настройки FAM через файл-разметки для него определено несколько специальных атрибутов, которые также могут быть изменены программно:


  • opened определяет будет ли FAM открыт при создании. (false по умолчанию)


  • content_res это LayoutRes, который представляет контент FAM (несколько кнопок, например). View, созданное из content_res добавляется в FAM как дочернее View. Контент может быть изменен программно во время работы приложения, поэтому FAM может быть указан атрибут android:animateLayoutChanges="true" для анимированного изменения контента. (по умолчанию контента нет)


  • can_close определяет будет ли FAM иметь кнопку для закрытия. (true по умолчанию)


  • close_icon это DrawableRes кнопки закрытия. (значение по умолчанию — крестик)


  • can_drag определяет будет ли FAM иметь кнопку для перетаскивания. (true по умолчанию)


  • drag_icon это DrawableRes кнопки перетаскивания. (есть значение по умолчанию)


  • can_dismiss определяет будет ли FAM закрываться, если пользователь утащит его по горизонтали достаточно далеко (true по умолчанию)


  • dismiss_threshold это пороговое значения сдвига по горизонтали начиная с которого FAM будет закрыт, когда пользователь отпустит drag_button. То есть, если (getTranslationX/getWidth) > dismissThreshold, то FAM будет закрыт. (0.4f по умолчанию)


  • minimize_direction определяет направление, в котором будет перемещаться FAM при сворачивании. Этот атрибут может иметь следующие значения (nearest по умолчанию):


    • none — FAM не будет перемещаться при сворачивании
    • top — FAM будет перемещаться к верхней границе родителя (исключая отступы) во время сворачивания
    • bottom — FAM будет перемещаться к нижней границе родителя (исключая отступы) во время сворачивания
    • nearest — FAM будет перемещаться к ближайшей (верхней или нижней) границе родителя (исключая отступы) во время сворачивания

  • animation_duration определяет длительность анимации сворачивания/разворачивания. (400 мс по умолчанию)

FAM также имеет OnCloseListener, который позволяет выполнить определенное действие при закрытии FAM пользователем (снять выделение с элементов списка, например).


Основные действия


Основными действиями с FAM являются открытие/закрытие и сворачивание/разворачивание. При открытии он появляется и разворачивается, а при закрытии сворачивается и исчезает.


Разворачивание FAM сопровождается анимацией, в процессе которой он перемещается от верхнего или нижнего края родительского ViewGroup (этот край задается атрибутом minimize_direction) в свое положение, заданное файлом разметки. Анимация задается следующим способом:


animate()
            .scaleY(1f)
            .scaleX(1f)
            .translationY(calculateArrangeTranslationY())
            .alpha(1f)

При сворачивании анимация выполняется «в обратную сторону»:


animate()
            .scaleY(0.5f)
            .scaleX(0.5f)
            .translationY(calculateMinimizeTranslationY())
            .alpha(0.5f)

Методы calculateArrangeTranslationY() и calculateMinimizeTranslationY() позволяют вычислить translationY для развернутого и свернутого состояний соответственно c учетом того, куда перетащил FAM пользователь, атрибута minimize_direction и отступов снизу и сверху, о которых будет рассказано далее.


Закрытие и перетаскивание


Для корректной и красивой работы FAM имеет кнопки (ImageView) с помощью которых пользователь может закрыть режим контекстных действий или перетащить в другую часть экрана по вертикали (если он загораживает нужный элемент списка). Также FAM может быть закрыт, если утащить его в сторону по горизонтали (swipe to dismiss).


FAM представляет собой LinearLayout, в который при создании добавляются кнопки для закрытия (drag_button) и перетаскивания (close_button). Возможность закрывать/перетаскивать FAM может быть включена/выключена во время работы приложения, поэтому LinearLayout, содержащий эти кнопки имеет атрибут android:animateLayoutChanges="true".


Разметка FAM


    

        

        

    

Механизм перетаскивания реализован с помощью OnTouchListener, который запоминает начальную точку касания и при движении устанавливает translationX и translationY соответственно касанию. Когда пользователь отпускает кнопку перетаскивания (drag_button), FAM возвращается в исходное положение по горизонтали и, если пользователь утащил FAM достаточно далеко по горизонтали, то вызывается метод this@FloatingActionMode.close().


OnTouchListener
drag_button.setOnTouchListener(object : OnTouchListener {
            var prevTransitionY = 0f
            var startRawX = 0f
            var startRawY = 0f

            override fun onTouch(v: View, event: MotionEvent): Boolean {
                if (!this@FloatingActionMode.canDrag) {
                    return false
                }

                val fractionX = Math.abs(event.rawX - startRawX) / this@FloatingActionMode.width

                when (event.actionMasked) {
                    MotionEvent.ACTION_DOWN -> {
                        this@FloatingActionMode.drag_button.isPressed = true
                        startRawX = event.rawX
                        startRawY = event.rawY
                        prevTransitionY = this@FloatingActionMode.translationY
                    }
                    MotionEvent.ACTION_MOVE -> {
                       this@FloatingActionMode.maximizeTranslationY =
                                prevTransitionY + event.rawY - startRawY
                        translationX = event.rawX - startRawX
                        if (canDismiss) {
                            val alpha =
                                    if (fractionX < dismissThreshold)
                                        1.0f
                                    else
                                        Math.pow(1.0 - (fractionX - dismissThreshold)
                                                / (1 - dismissThreshold), 4.0).toFloat()
                            this@FloatingActionMode.alpha = alpha
                        }
                    }
                    MotionEvent.ACTION_UP -> {
                        drag_button.isPressed = false
                        this@FloatingActionMode.animate().translationX(0f)
                                .duration = animationDuration
                        if (canDismiss && fractionX > dismissThreshold) {
                            this@FloatingActionMode.close()
                        }
                    }
                }
                return true
            }
        })

Использование в CoordinatorLayout


Ранее говорилось, что методы calculateArrangeTranslationY() и calculateMinimizeTranslationY() учитывают отступы сверху и снизу для определения правильного положения FAM. Эти отступы вычисляются с помощью FloatingActionModeBehavior — расширения CoordinatorLayout.Behavior, задающего верхний отступ как высоту AppBarLayout, а нижний отступ как высоту видимой части Snackbar.SnackbarLayout.


Также FloatingActionModeBehavior позволяет FAM реагировать на скролл, сворачиваясь при скроллинге вниз и разворачиваясь при скроллинге вверх (quick return pattern).


По умолчанию FAM не имеет background, поэтому вы можете использовать любой какой нужно. Также для создания тени на устройствах с API>=21 может использоваться атрибут android:translationZ="8dp"


FloatingActionModeBehavior
    open class FloatingActionModeBehavior
    @JvmOverloads constructor(context: Context? = null, attrs: AttributeSet? = null)
        : CoordinatorLayout.Behavior(context, attrs) {

        override fun layoutDependsOn(parent: CoordinatorLayout?,
                                     child: FloatingActionMode?, dependency: View?): Boolean {
            return dependency is AppBarLayout || dependency is Snackbar.SnackbarLayout
        }

        override fun onDependentViewChanged(parent: CoordinatorLayout,
                                            child: FloatingActionMode, dependency: View): Boolean {
            when (dependency) {
                is AppBarLayout -> child.topOffset = dependency.bottom
                is Snackbar.SnackbarLayout ->
                    child.bottomOffset = dependency.height - dependency.translationY.toInt()
            }
            return false
        }

        override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout?,
                                         child: FloatingActionMode?, directTargetChild: View?,
                                         target: View?, nestedScrollAxes: Int): Boolean {
            return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL
        }

        override fun onNestedScroll(coordinatorLayout: CoordinatorLayout,
                                    child: FloatingActionMode, target: View, dxConsumed: Int,
                                    dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
            super.onNestedScroll(coordinatorLayout, child, target,
                    dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)

            // FAM не должен реагировать на скроллинг своих дочерних View.
            var parent = target.parent
            while (parent != coordinatorLayout) {
                if (parent == child) {
                    return
                }
                parent = parent.parent
            }

            if (dyConsumed > 0) {
                child.minimize(true)
            } else if (dyConsumed < 0) {
                child.maximize(true)
            }
        }
    }

Вот так FAM может выглядеть в файле разметки:




    
        ...
    

    

    


Исходный код


Исходный код FloatingActionMode доступен на GitHub (директория library). Там же находится demo приложение, использующее FAM (директория app).


Сам FloatingActionMode, а также FloatingActionModeBehavior определены как open классы, поэтому Вы можете модернизировать их так, как Вам требуется. Ключевые методы FloatingActionMode также определены как open.


Спасибо за внимание. Happy coding!

Комментарии (1)

  • 11 января 2017 в 12:19 (комментарий был изменён)

    0

    Мне кажется, это совсем не в духе Материального Дизайна. Перетаскиваемые панельки, ручное управление окнами (и вообще многооконность вместо задачеориентированности) — это UX девяностых-нулевых годов. Современному пользователю не принято предлагать двигать кнопки (выполняя недоделанную работу UX/GUI-дизайнера), ему принято предоставлять кнопки сразу на Самом Удобном Месте™ (иначе это ему предложат конкуренты).

© Habrahabr.ru