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

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


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


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


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


    Art
    FloatingActionMode во время работы (Зафиксирован снизу)


    Видео — пример работы с FloatingActionMode (Зафиксирован снизу)


    В комментариях было указано, что пользователю может быть не очень удобно перетаскивать панель по экрану, поэтому она может быть закреплена в нижней части экрана, как показано на скринах и видео выше. (Для этого нужно указать атрибуты android:layout_gravity="bottom" и app:fam_can_drag="false").


    В то же время, можно позволить пользователю перемещать FAM по экрану, как показано на следующих скринах и видео.


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


    Видео — пример работы с FloatingActionMode (Перетаскивание)


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


    XML-атрибуты


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


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


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


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


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


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


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


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


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


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


      • topFAM будет перемещаться к верхней границе родителя (исключая отступы) во время сворачивания
      • bottomFAM будет перемещаться к нижней границе родителя (исключая отступы) во время сворачивания
      • nearestFAM будет перемещаться к ближайшей (верхней или нижней) границе родителя (исключая отступы) во время сворачивания

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

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


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


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


    Разворачивание FAM сопровождается анимацией, в процессе которой он перемещается от верхнего или нижнего края родительского ViewGroup (этот край задается атрибутом fam_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 пользователь, атрибута fam_minimize_direction и отступов снизу и сверху, о которых будет рассказано далее.


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


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


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


    Разметка FAM
    <?xml version="1.0" encoding="utf-8"?>
    <merge xmlns:android="http://schemas.android.com/apk/res/android">
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="?attr/actionBarSize"
            android:animateLayoutChanges="true"
            android:layout_gravity="center_vertical">
    
            <ImageView
                android:id="@+id/fam_close_button"
                android:layout_width="?attr/actionBarSize"
                android:layout_height="?attr/actionBarSize"
                android:layout_gravity="center_vertical"
                android:background="@drawable/fam_image_button_background"
                android:scaleType="center"
                android:src="@drawable/fam_ic_close_white_24dp"/>
    
            <ImageView
                android:id="@+id/fam_drag_button"
                android:layout_width="?attr/actionBarSize"
                android:layout_height="?attr/actionBarSize"
                android:layout_gravity="center_vertical"
                android:background="@drawable/fam_image_button_background"
                android:scaleType="center"
                android:src="@drawable/fam_ic_drag_white_24dp"/>
    
        </LinearLayout>
    </merge>

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


    OnTouchListener
    fam_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.fam_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 -> {
                            fam_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).


    FloatingActionModeBehavior
        open class FloatingActionModeBehavior
        @JvmOverloads constructor(context: Context? = null, attrs: AttributeSet? = null)
            : CoordinatorLayout.Behavior<FloatingActionMode>(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 может выглядеть в файле разметки:


    <android.support.design.widget.CoordinatorLayout>
    
        <android.support.design.widget.AppBarLayout>
            ...
        </android.support.design.widget.AppBarLayout>
    
        <android.support.v7.widget.RecyclerView
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
    
        <com.qwert2603.floating_action_mode.FloatingActionMode
            android:id="@+id/floating_action_mode"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/action_mode_margin"
            android:animateLayoutChanges="true"
            android:background="@drawable/action_mode_background"
            android:translationZ="8dp"
            app:fam_animation_duration="@integer/action_mode_animation_duration"
            app:fam_can_dismiss="true"
            app:fam_can_drag="true"
            app:fam_content_res="@layout/user_list_action_mode_2"
            app:fam_dismiss_threshold="0.35"
            app:fam_drag_icon="@drawable/ic_drag_white_24dp"
            app:fam_minimize_direction="nearest"/>
    
    </android.support.design.widget.CoordinatorLayout>

    Исходный код


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


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


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

    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 8
    • +2
      Мне кажется, это совсем не в духе Материального Дизайна. Перетаскиваемые панельки, ручное управление окнами (и вообще многооконность вместо задачеориентированности) — это UX девяностых-нулевых годов. Современному пользователю не принято предлагать двигать кнопки (выполняя недоделанную работу UX/GUI-дизайнера), ему принято предоставлять кнопки сразу на Самом Удобном Месте™ (иначе это ему предложат конкуренты).
      • 0
        Возможно, вы правы и пользователю не очень удобно будет перетаскивать панель контекстных действий. Но цель FloatingActionMode — не загораживать Toolbar, предоставляя действия над элементами списка. Перетаскивать его потребуется только в том случае, если он загородил нужный элемент. Хотя, можно и просто немного проскролить список, чтобы нужный элемент стал виден. Контекстные действия не всегда видны пользователю, поэтому я считаю, что появление/исчезновение и возможность перетащить панель таких действий имеют право на существование. И, как верно заметил MaxBykov, разработка таких вещей приносит пользу (и опыт).
        • 0
          F почему бы тогда не сделать эту панель намертво прибитой к нижней границе экрана, заодно и без скруглений и более в стиле material design?
          • 0
            Это возможно сделать, отключив перетаскивание и указав android:layout_gravity=«bottom». Я обновил статью, добавив скрины и видео с закрепленным FAM.
      • 0
        Не думаю что такой подход делает интерфейс понятным или удобным. Лучше уж стандартный ActionMode использовать. Хотя сама разработка подобных вещей может принести много пользы в будущем, вдруг ваша следующая попытка будет удачной.
        • 0
          А разве ActionMode доступен не только для ListView? Поправьте, если ошибаюсь, но для реализации подобной фичи для Recycler'а нужны собственные костыли (ну или чьи то другие)
        • 0
          Идея интересная. Но при просмотре видео, я как рядовой пользователь, не понял что я могу с ним делать) Ну выскочило окошко с кучей кнопок, ну и ладно)
          • 0
            Окошко появляется при выделении элемента списка и предназначено для действий с элементами. (На том же Toolbar также появляются кнопки).

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.