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!

    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 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 также появляются кнопки).

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