Контекстные действия с элементами списка широко используются с 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!