Рецепты под Android: Scroll-To-Dismiss Activity

в 11:07, , рубрики: activity, android development, scroll-to-dismiss, Блог компании EastBanc Technologies, Разработка под android

Привет! Сегодня мы расскажем, как за минимальное количество времени добавить в свою Activity поведение Scroll-To-Dismiss. Scroll-To-Dismiss – это популярный в современном мире жест, позволяющий закрыть текущий экран и вернуться в предыдущую Activity.

Рецепты под Android: Scroll-To-Dismiss Activity - 1

В один прекрасный день нам поступил реквест на добавление такой функциональности в одно из наших новостных приложений. Если вам интересно, как легко добавить такую функциональность в уже существующую Activity и избежать возможных проблем – добро пожаловать под кат.

Что имеем?

Решение "в лоб" довольно очевидное: использовать одну Activity и пару фрагментов, положение которых можно регулировать в рамках одной Activity.

У нас такой подход вызывал некоторые сомнения, так как, приложение уже имело сложившуюся навигацию: отдельная Activity для списка новостей и отдельная Activity для чтения самой статьи.

Несмотря на то, что функционально список статей и чтение статьи были уже декомпозированы в отдельные соответствующие фрагменты, это не спасало. Так как сами фрагменты требовали от хостящей их Activity иметь определенный интерфейс и реализацию (как это обычно и бывает с фрагментами). Помимо этого, UI этих экранов довольно сильно различался: разный набор кнопок меню, разное поведение тулбара (Behavior).

Суммарно это все делало объединение двух экранов в один ради одного дизайнерского твика иррациональным.

Рецепты под Android: Scroll-To-Dismiss Activity - 2

Сам паттерн навигации, как уже говорилось, довольно популярный. Так что неудивительно, что в Android API уже есть некоторые возможности по его реализации. Помимо уже озвученного решения "в лоб" можно было бы использовать:

  • BottomSheetFragmentDialog. Это новый FragmentDialog, доступный в дизайнерской библиотеке от Google. Его поведение весьма похоже на нужное нам, но его все равно нужно хостить в рамках одной с контентом Activity, чего мы не хотим. Более того, BottomSheetFragmentDialog требует в layout-е наличия CoordinatorLayout, что вам может не понравиться. А еще этот диалог можно "смахнуть" только вниз.
  • Библиотеки. Android comunity богато на библиотеки на все случаи жизни. Для scroll-to-dismiss тоже нашлась парочка: SwipeBack и android-slidingactivity.

К сожалению, они нам тоже не подошли, так как или требуют наличия кастомной layout-обертки, поведение которой конфликтует с поведением внутренних компонентов, или имеют слишком закрытый для настройки API.

Движение – это жизнь

Менять положение Activity у нас не очень получится, зато мы можем перемещать её контент. Мы будем следить за движением пальца пользователя и соответствующе менять координаты самого верхнего контейнера в иерархии. Давайте сделаем базовый класс, который можно будет переиспользовать для любой Activity.

Примеры кода будут на Kotlin, потому, что он компактнее :)

abstract class SlidingActivity : AppCompatActivity() {

    private lateinit var root: View

    override fun onPostCreate(savedInstanceState: Bundle?) {
        super.onPostCreate(savedInstanceState)
        root = getRootView() // попросим наследника дать нам корневой элемент иерархии
    }

    abstract fun getRootView(): View
}    

Далее научимся слушать и реагировать на жесты пользователя. Можно было бы обернуть корневой элемент в свой контейнер и отслеживать действия в нём, но мы пойдем другим путем. В Activity можно переопределить метод dispatchTouchEvent(...), который является первым обработчиком касаний экрана. Заготовку обработчика вы можете видеть ниже:

abstract class SlidingActivity : AppCompatActivity() {

    private lateinit var root: View

    override fun onPostCreate(savedInstanceState: Bundle?) {
        super.onPostCreate(savedInstanceState)
        root = getRootView()
    }

    abstract fun getRootView(): View

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {

        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // запомним начальные координаты
            }
            MotionEvent.ACTION_MOVE -> {
                // определим, куда двигается палец и нужно ли сдвигать контент
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // закроем Activity, если контент "сдвинут" 
                // на значительное расстояние или вернем все как было
            }
        }

        // передать event всем остальным обработчикам
        return super.dispatchTouchEvent(ev)
    }
}

Понять, что пользователь ведет пальцем сверху вниз (чтобы "смахнуть" экран), не сложно: координата y всех следующих за начальной позицией событий увеличивается, а x может колебаться в каком-то незначительном интервале. С этим проблем, как правило, не возникает. Проблемы начинаются, когда на экране присутсвуют другие прокручиваемые элементы: ViewPager, RecyclerView, Toolbar с некоторым Behavior, их наличие нужно всегда иметь в виду:

abstract class SlidingActivity : AppCompatActivity() {

    private lateinit var root: View
    private var startX = 0f
    private var startY = 0f
    private var isSliding = false
    private val GESTURE_THRESHOLD = 10
    private lateinit var screenSize : Point

    override fun onPostCreate(savedInstanceState: Bundle?) {
        super.onPostCreate(savedInstanceState)
        root = getRootView()
        screenSize = Point().apply { windowManager.defaultDisplay.getSize(this) }
    }

    abstract fun getRootView(): View

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        var handled = false

        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // запоминаем точку старта
                startX = ev.x
                startY = ev.y
            }

            MotionEvent.ACTION_MOVE -> {

                // нужно определить, является ли текущий жест "смахиванием вниз"
                if ((isSlidingDown(startX, startY, ev) && canSlideDown()) || isSliding) {
                    if (!isSliding) {
                        // момент, когда мы определили, что польователь "смахивает" экран
                        // начиная с этого жеста все последующие ACTION_MOVE мы будем
                        // воспринимать как "смахивание"
                        isSliding = true
                        onSlidingStarted()

                        // сообщим всем остальным обработчикам, что жест закончился
                        // и им не нужно больше ничего обрабатывать
                        ev.action = MotionEvent.ACTION_CANCEL
                        super.dispatchTouchEvent(ev)
                    }

                    // переместим контейнер на соответсвующую Y координату
                    // но не выше, чем точка старта
                    root.y = (ev.y - startY).coerceAtLeast(0f)

                    handled = true
                }
            }

            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                if (isSliding) {
                    // если пользователь пытался "смахнуть" экран...
                    isSliding = false
                    onSlidingFinished()
                    handled = true
                    if (shouldClose(ev.y - startY)) {
                        // закрыть экран
                    } else {
                        // вернуть все как было
                        root.y = 0f
                    }
                }
                startX = 0f
                startY = 0f

            }
        }
        return if (handled) true else super.dispatchTouchEvent(ev)
    }

    private fun isSlidingDown(startX: Float, startY: Float, ev: MotionEvent): Boolean {
        val deltaX = (startX - ev.x).abs()
        if (deltaX > GESTURE_THRESHOLD) return false
        val deltaY = ev.y - startY
        return deltaY > GESTURE_THRESHOLD
    }

    abstract fun onSlidingFinished()

    abstract fun onSlidingStarted()

    abstract fun canSlideDown(): Boolean

    private fun shouldClose(delta: Float): Boolean {
        return delta > screenSize.y / 3
    }
}

Обратите внимание, что мы добавили новый абстрактный метод canSlideDown() : Boolean. Им мы спрашиваем у наследника, является ли текущий момент подходящим, чтобы начать наш Scroll-ToDismiss жест. Например, если пользователь читает статью и находится где-то на середине текста, то жестом пальца сверху вниз он наверняка хочет прокрутить статью повыше, вместо того, чтобы закрыть весь экран.

Вторым важным моментом является тот факт, что наш обработчик перестает отдавать события дальше по цепочке (не вызвается super.dispatchTouchEvent(ev)) начиная с того момента, как определил нужный нам жест. Это нужно для того, чтобы все вложенные прокручиваемые виджеты перестали реагировать на движения пальца и двигать контент самостоятельно. Перед тем как обрубить цепочку обработки, мы посылаем MotionEvent.ACTION_CANCEL, чтобы вложенные элементы не рассматривали внезапно прервавшийся поток сообщений как "Long Click".

Доводим до готовности

Когда пользователь поднял палец, и мы поняли, что экран можно закрывать, мы не можем вызвать Activity.finish() в тот же момент. Точнее можем, конечно, но это будет выглядеть как внезапно закрывшийся экран. Что нам нужно сделать, так это анимировать root контейнер вниз экрана и уже после этого закрыть Activity:

private fun closeDownAndDismiss() {
    val start = root.y
    val finish = screenSize.y.toFloat()
    val positionAnimator = ObjectAnimator.ofFloat(root, "y", start, finish)
    positionAnimator.duration = 100
    positionAnimator.addListener(object : Animator.AnimatorListener {
        override fun onAnimationRepeat(animation: Animator) {}

        override fun onAnimationEnd(animation: Animator) {
            finish()
        }

        override fun onAnimationCancel(animation: Animator) {}

        override fun onAnimationStart(animation: Animator) {}

    })
    positionAnimator.start()
}

Последнее, что нам осталось – сделать нашу Activity прозрачной, чтобы при смахивании был виден экран, который она перекрывает. Чтобы добиться такого эффекта, просто добавьте к теме вашей Activity такие атрибуты:

<style name="MyTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:colorBackgroundCacheHint">@null</item>
    <item name="android:windowFrame">@null</item>
    <item name="android:windowContentOverlay">@null</item>
    <item name="android:windowAnimationStyle">@null</item>
    <item name="android:windowIsFloating">false</item>
    <item name="android:windowIsTranslucent">true</item>
    <item name="android:windowNoTitle">true</item>
</style>

Чтобы Scroll-To-Dismiss выглядел еще круче, можно добавить эффект затемнения заднего экрана по мере прокрутки:

override fun onCreate(savedInstanceState: Bundle?) {

    <...>

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        window.statusBarColor = Color.TRANSPARENT
    }

    windowScrim = ColorDrawable(Color.argb(0xE0, 0, 0, 0))
    windowScrim.alpha = 0
    window.setBackgroundDrawable(windowScrim)
}

private fun updateScrim() {
    val progress = root.y / screenSize.y
    val alpha = (progress * 255f).toInt()
    windowScrim.alpha = 255 - alpha
}

По мере смещения корневого контейнера (пальцем или анимацией) просто вызваейте updateScrim() и фон будет динамически меняться.

Итог

Таким довольно простым способом мы получили не только требуемое поведение, но и возможность гибко влиять на поведение.

Например, при желании, можно научить нашу Activity смахиваться вверх или в бок. Жесты, перехватываемые на уровне Activity не ломают поведение внутренних компонентов, таких как ViewPager, RecyclerView и даже AppbarLayout + Custom Behavior.

Пользуйтесь на здоровье!

Автор: eastbanctech

Источник


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


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