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