Привет, Хабр! Меня зовут Илья Осинцев, я Android-разработчик в компании Apiqa. Под катом вас ждет пример использования ViewDragHelper для создания компонента пользовательского интерфейса аналогичного SwipeDismissBehavior, но работающего вертикально.
С появлением Material Design в приложениях стало больше интерактивных элементов, которые реагируют на действия пользователя. Они не только экономят место, но и вводят забавные микровзаимодействия. В нескольких наших проектах мы решили использовать вертикально перемещаемые баннеры по механике swipe-to-dismiss. Для придания живости интерфейсу баннеры должны учитывать скорость движения указателя и изменять прозрачность в зависимости от направления смещения.
Оцениваем задачу
Личный кабинет | Кабинет техника |
---|---|
В нашем приложении «Личный кабинет» баннер выступает в качестве быстрого способа оставить обращение на услугу поиска арендатора для своего жилья. В приложении «Кабинет техника» баннер позволяет сохранить контекст работы пользователя с заданием при переходе от информационной карточкой к комментариями. В первом случае мы подчеркиваем опциональность услуги ПИК-Аренда и даем клиенту почувствовать себя в приложении «как дома». В другом случае мы реализуем свайп по подсказке, чтобы она не перекрывала ленту сообщений между диспетчером и исполнителями.
Для начала я собрал простенькое демо на основе SwipeDismissBehavior, чтобы изучить, как он работает, и прикинуть масштаб изменений. Попытка указать его в xml разметке приводит к исключению при выполнении:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: io.apiqa.android.example, PID: 1024
android.view.InflateException: Binary XML file line #115: Could not inflate Behavior subclass com.google.android.material.behavior.SwipeDismissBehavior
Разработчики нарушили контракт Behavior и забыли переопределить конструктор класса от контекста и AttributeSet. Сейчас использовать этот behavior можно только создавая его экземпляр в своем коде, но даже тогда поведение view принципиально не удовлетворяет нашим требованиям, даже без учета горизонтального направления.
В логах демо-приложения нашлись сообщения о том что, часть событий касания не попадает в обработчик.
E/ViewDragHelper: Ignoring pointerId=-1 because ACTION_DOWN was not received for this pointer before ACTION_MOVE. It likely happened because ViewDragHelper did not receive all the events in the event stream.
В качестве альтернативы было решение на основе OnTouchListener
, оно лишает нас возможности использовать OnClickListener, а значит мы должны будем описать закон движения баннера в activity. Мы не хотим по мере движения изменять параметры перемещения (например, чувствительность) и использование OnTouchListener здесь кажется излишним. К тому же в обоих наших проектах баннеры размещались в CoordinatorLayout.
Если не принимать во внимание геттеры и сеттеры опциональных параметров, сам SwipeDismissBehavior довольно короткий, в нем используется ViewDragHelper
. Я нашёл в сети несколько публикаций о нём и решил написать собственную реализацию требуемого компонента.
Координируем с ViewDragHelper
ViewDragHelper это служебный класс для облегчения поддержки в приложении drag&drop на уровне View. Он отслеживает положение виджетов и содержит несколько полезных функций по их анимированному перемещению по одной или двум осям внутри родительской ViewGroup. Для работы ему требуется обработчик, реализующий ViewDragHelper.Callback. У обработчика один обязательный метод, а чтобы баннер начал передвигаться, достаточно переопределить еще пару. В целом этот хэлпер просто использовать, он доступен в любом проекте, так как поставляется вместе с appcompat. Для создания хэлпера требуется ссылка на родительский CoordinatorLayout, поэтому организуем ленивую инициализацию. В onInterceptTouchEvent
и onTouchEvent
мы должны вызвать соответствующие методы хэлпера, остальная логика будет находится внутри обработчика.
class VerticalSwipeBehavior<V: View>: CoordinatorLayout.Behavior<V> {
companion object {
@Suppress("UNCHECKED_CAST")
fun <V: View> from(v: V): VerticalSwipeBehavior<V> {
val lp = v.layoutParams
require(lp is CoordinatorLayout.LayoutParams)
val behavior = lp.behavior
requireNotNull(behavior)
require(behavior is VerticalSwipeBehavior)
return behavior as VerticalSwipeBehavior<V>
}
}
@Suppress("unused")
constructor() : super()
@Suppress("unused")
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
private var dragHelper: ViewDragHelper? = null
private var interceptingEvents = false
override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean {
var isIntercept = interceptingEvents
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isIntercept = parent.isPointInChildBounds(child, ev.x.toInt(), ev.y.toInt())
interceptingEvents = isIntercept
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
interceptingEvents = false
}
}
return if (isIntercept) {
helper(parent).shouldInterceptTouchEvent(ev)
} else false
}
override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean {
val helper = helper(parent)
val isViewUnder = helper.isViewUnder(child, ev.x.toInt(), ev.y.toInt())
if (helper.capturedView == child || isViewUnder ) {
helper.processTouchEvent(ev)
return true
} else {
return false
}
}
private fun helper(parent: ViewGroup): ViewDragHelper {
var h = dragHelper
if (h == null) {
h = ViewDragHelper.create(parent, callback)
dragHelper = h
return h
}
return h
}
}
В обязательном методе обработчика tryCaptureView
мы должны решить, следует ли перемещать конкретную view. Чтобы хэлпер не пропускал нужные события, допускаем тот указатель, который был получен ранее. Чтобы получить максимально гибкое решение вводятся три дополнительных интерфейса, посредством которых можно детально управлять дизайном баннера:
SideEffect
отражает прогресс свайпа в свойствах viewVerticalClamp
предназначен для ограничения движения view по вертикалиPostAction
вызывается после того как пользователь прекращает свайп, здесь мы можем продолжить движение view.
В каждом из них объявлен метод onViewCaptured(View)
, тут клиентские реализации могут извлечь начальные значения свойств view. Порядок вызовов этого метода не гарантируется.
var sideEffect: SideEffect = AlphaElevationSideEffect()
var clamp: VerticalClamp = FractionConstraintWithTopMargin(1f, 1f)
var settle: PostAction = OriginSettleAction()
private val callback = object: ViewDragHelper.Callback() {
private val INVALID_POINTER_ID = -1
private var currentPointer = INVALID_POINTER_ID
private var originTop: Int = 0
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
return currentPointer == INVALID_POINTER_ID || pointerId == currentPointer
}
override fun onViewCaptured(child: View, activePointerId: Int) {
originTop = child.top
currentPointer = activePointerId
sideEffect.onViewCaptured(child)
settle.onViewCaptured(child)
clamp.onViewCaptured(child.top)
}
override fun onViewReleased(child: View, xvel: Float, yvel: Float) {
// TODO
currentPointer = INVALID_POINTER_ID
}
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int) = child.left
// TODO
}
Хотя наша view не перемещается по вертикали, в обработчике нужно не забыть реализовать clampViewPositionHorizontal
, чтобы избежать визуальных багов. Простая реализация возвращает координату left, это означает что виджет не двигается по горизонтали.
В нашем случае вызов clampViewPositionVertical
обработчика делегируется интерфейсу VerticalClamp. Метод constraint
должен вернуть координату по высоте, ограниченную максимальным и/или минимальным положением view. При её достижении ViewDragHelper ограничит перемещение. Методы upCast(distance, top, height, dy)
и downCast
имеют одинаковую сигнатуру и возвращают долю от пройденного пути с учетом начального положения view. В методе обработчика onViewPositionChanged
мы получаем прогресс перемещения и передаем его в SideEffect#apply(View, Float)
, в котором можно изменить прозрачность или другие свойства view в зависимости от прогресса жеста. Если текущее положение view выше начального, то прогресс передается со знаком минус.
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
return clamp.constraint(child.height, top, dy)
}
override fun onViewPositionChanged(child: View, left: Int, top: Int, dx: Int, dy: Int) {
val factor = if (top < originTop) {
val diff = originTop - top
-clamp.bottomCast(diff, top, child.height, dy)
} else {
val diff = top - originTop
clamp.topCast(diff, top, child.height, dy)
}
sideEffect.apply(child, factor)
}
По умолчанию используется FractionClamp
, который ограничивает перемещение view на одну ее высоту вверх и вниз (коэффициенты задаются в конструкторе), AlphaElevationSideEffect
изменяет прозрачность и elevation у баннера. Чтобы считать задачу выполненной необходимо добавить возможность анимированного перемещения баннера после того, как пользователь его отпустил.
Когда пользователь отпустит view, хэлпер запомнит скорость указателя и вызовет onViewReleased
у обработчика. В нём мы можем запустить анимацию передвижения с помощью settleCapturedViewAt
или smoothSlideViewTo
. По контракту после успешного вызова любого из них следует на каждом следующем кадре вызывать continueSettling
чтобы view продолжала движение. При этом settleCapturedViewAt можно вызывать только из метода onViewReleased, когда внутренний флаг хэлпера mReleaseInProgress устанавливается в true. Ещё одно отличие заключается в том, что smoothSlideViewTo не учитывает скорость движения указателя.
override fun onViewReleased(child: View, xvel: Float, yvel: Float) {
val diff = child.top - originTop
if (abs(yvel) > 0) {
val settled = dragHelper?.let {
if (diff > 0) {
settle.releasedBelow(it, diff, child)
} else {
settle.releasedAbove(it, diff, child)
}
} ?: false
if (settled) {
listener?.onPreSettled(diff)
child.postOnAnimation(RecursiveSettle(child, diff))
}
}
currentPointer = INVALID_POINTER_ID
}
Эта логика инкапсулируется в интерфейс PostAction
. Методы releasedAbove
и releasedBelow
можно реализовать так, чтобы при смещении вверх баннер продолжал перемещаться с прежней скоростью, уходя за экран, а при смещении вниз — вернулся на исходную позицию. Если какой-то из методов возвращает true, значит анимация была инициирована и в очередь событий view добавляется RecursiveSettle, который будет находиться в ней до завершения анимации. По умолчанию используется OriginSettleAction
, когда view при любом смещении возвращается к начальной точке. Другой вариант – SettleOnTopAction
при перемещении view вниз возвращает ее в начальную точку, а при перемещении выше — уводит за экран.
class SettleOnTopAction: PostAction {
private var originTop: Int = -1
override fun onViewCaptured(child: View) {
originTop = child.top
}
override fun releasedAbove(helper: ViewDragHelper, child: View): Boolean {
return helper.settleCapturedViewAt(child.left, originTop)
}
override fun releasedBelow(helper: ViewDragHelper, child: View): Boolean {
return helper.settleCapturedViewAt(child.left, -child.height)
}
}
Если нужно, вы можете подписаться на события, реализовав интерфейс VerticalSwipeBehavior.SwipeListener
. Он имеет два симметричных метода, один вызывается перед стартом анимации перемещения, другой − после ее окончания. Аргумент показывает направление и дистанцию, с которой пользователь отпустил баннер. Получившийся результат удовлетворяет нашим требованиям.
Чтобы получить его результат достаточно определить свойства следующим образом:
val drag = findViewById<View>(R.id.drag)
VerticalSwipeBehavior.from(drag).apply {
settle = SettleOnTopAction()
sideEffect = NegativeFactorFilterSideEffect(AlphaElevationSideEffect())
clamp = BelowFractionalClamp()
}
К слову, хэлпер предоставляет и другие возможности по управлению движением. Например, с помощью метода setMinVelocity(Float)
можно ограничить минимальную скорость перемещения view. Хэлпер также поддерживает распознавание свайпов от границ экрана, для этого их нужно указать в методе setEdgeTrackingEnabled(Int)
. Следует помнить, что один экземпляр ViewDragHelper может управлять движением только одной view и учитывает только один указатель.
Делаем выводы
Мой опыт показывает, что ViewDragHelper упрощает создание drag&drop или перемещающихся панелей в приложении. Хэлпер легко использовать в Behavior или переопределенной ViewGroup. Он имеет несколько полезных методов для анимации view и контролирует их перемещение. Изучение внутренней реализации компонентов Material Design – это хороший опыт в карьере Android-разработчика. Такие задачи от дизайнеров мотивируют меня изучать новые подходы к построению интерфейса приложений и делиться знаниями с коллегами по работе и сообществом.
Вы можете использовать получившуюся библиотеку в своих проектах. Чтобы ее подключить, укажите зависимость в файле build.gradle
dependencies {
implementation 'io.apiqa.android:verticalswipebehavior:1.0.0'
}
Для подходящего view внутри CoordinatorLayout укажите свойство app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior"
в разметке. Позиционировать баннер в пределах родителя можно с помощью отступов. Согласовав реализции SideEffect, VerticalClamp и PostAction можно добиться нужного вам поведения баннера. В репозитории доступны рабочие варианты каждого из них.
Счастливого Нового года!