Пишем гибкий VerticalSwipeBehavior

в 11:32, , рубрики: android, CoordinatorLayout, viewdraghelper, разработка мобильных приложений, Разработка под android, статьи от котиков

Привет! Меня зовут Илья Осинцев, я 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 принципиально не удовлетворяет нашим требованиям, даже без учета горизонтального направления.

SwipeDismissBehavior из коробки

В логах демо-приложения нашлись сообщения о том что, часть событий касания не попадает в обработчик.

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 отражает прогресс свайпа в свойствах view
  • VerticalClamp предназначен для ограничения движения 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 можно добиться нужного вам поведения баннера. В репозитории доступны рабочие варианты каждого из них.

Счастливого Нового года!

Автор: apiqa

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js