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

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

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по умолчанию):
top—FAMбудет перемещаться к верхней границе родителя (исключая отступы) во время сворачиванияbottom—FAMбудет перемещаться к нижней границе родителя (исключая отступы) во время сворачиванияnearest—FAMбудет перемещаться к ближайшей (верхней или нижней) границе родителя (исключая отступы) во время сворачивания
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".
<?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().
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).
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)

MaxBykov
11.01.2017 13:30Не думаю что такой подход делает интерфейс понятным или удобным. Лучше уж стандартный ActionMode использовать. Хотя сама разработка подобных вещей может принести много пользы в будущем, вдруг ваша следующая попытка будет удачной.

abbath0767
11.01.2017 14:33А разве ActionMode доступен не только для ListView? Поправьте, если ошибаюсь, но для реализации подобной фичи для Recycler'а нужны собственные костыли (ну или чьи то другие)

dingping
11.01.2017 14:04Идея интересная. Но при просмотре видео, я как рядовой пользователь, не понял что я могу с ним делать) Ну выскочило окошко с кучей кнопок, ну и ладно)

qwert2603
11.01.2017 14:06Окошко появляется при выделении элемента списка и предназначено для действий с элементами. (На том же Toolbar также появляются кнопки).
napa3um
Мне кажется, это совсем не в духе Материального Дизайна. Перетаскиваемые панельки, ручное управление окнами (и вообще многооконность вместо задачеориентированности) — это UX девяностых-нулевых годов. Современному пользователю не принято предлагать двигать кнопки (выполняя недоделанную работу UX/GUI-дизайнера), ему принято предоставлять кнопки сразу на Самом Удобном Месте™ (иначе это ему предложат конкуренты).
qwert2603
Возможно, вы правы и пользователю не очень удобно будет перетаскивать панель контекстных действий. Но цель FloatingActionMode — не загораживать Toolbar, предоставляя действия над элементами списка. Перетаскивать его потребуется только в том случае, если он загородил нужный элемент. Хотя, можно и просто немного проскролить список, чтобы нужный элемент стал виден. Контекстные действия не всегда видны пользователю, поэтому я считаю, что появление/исчезновение и возможность перетащить панель таких действий имеют право на существование. И, как верно заметил MaxBykov, разработка таких вещей приносит пользу (и опыт).
ebragim
F почему бы тогда не сделать эту панель намертво прибитой к нижней границе экрана, заодно и без скруглений и более в стиле material design?
qwert2603
Это возможно сделать, отключив перетаскивание и указав android:layout_gravity=«bottom». Я обновил статью, добавив скрины и видео с закрепленным FAM.