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



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


Что имеем?


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


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


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


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




Сам паттерн навигации, как уже говорилось, довольно популярный. Так что неудивительно, что в 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.


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

Поделиться с друзьями
-->

Комментарии (16)


  1. IgeNiaI
    31.05.2017 16:22

    Спасибо, интересная реализация.
    Думаю, было бы неплохо такую фишку стандартизировать, чтобы пользователям не нужно было запоминать свайпы для каждого приложения. Желательно в виде библиотеки, где разработчикам всего то и нужно, что наследовать свои Activity от вашей реализации.
    Ну и еще я думаю, что свайп слева как в iOS был бы лучше, чем сверху, так как достать туда проще. Да и кнопка и стрелка «назад» показывают именно влево.


    1. IgeNiaI
      31.05.2017 16:28

      А, ну еще так как большинство приложений являются по сути вертикальными списками с различным контентом, то свайп сверху действительно неуместен в большинстве случаев.


    1. forceLain
      31.05.2017 16:40
      +1

      Конкретно в этой реализации необязательно тянуть палец до самого заголовка. Свайп можно начать с любого места.


  1. alexparshin
    01.06.2017 06:36

    Атрибут android:windowIsTranslucent приведет к тому, что перекрываемая activity не перейдет в stopped состояние.


    1. eastbanctech
      01.06.2017 06:46

      Добрый день! Именно для этого мы его и написали — чтобы задняя activity была видна в любой момент времени.


      1. Tusur
        01.06.2017 07:23

        По этому наверно правильнее делать на фрагментах, так как они в любом случае остаются видимыми.


        1. forceLain
          01.06.2017 07:37

          так как они в любом случае остаются видимыми

          Это зависит от того, как мы совершаем транзакцию: add или replace. Replace нам удалит предыдущий фрагмент с экрана, так что он тоже не будет видимым и работать это не будет. Add добавит фрагмент поверх предыдущего, однако предыдущий фрагмент не получит onPause()/onResume() колбэков, в отличие от примера с activity. Если использовать подход с фрагментами, то лучше сразу использовать BottomSheetFragmentDialog из дизайнерской либы.

          Я бы сказал, что в общем случае лучше руководствоваться здравым смыслом. Если контент фрагментов слишком разный (стили, цвета, набор иконок в тулбаре, наличие и отсутствие бокового меню, интеграция с context), то объединение их в одну активити ради scroll-to-dismiss принесет больше проблем, чем профита.


          1. terrakok
            01.06.2017 12:21

            Но активити сама по себе как системный класс — тяжелее фрагмента. совсем не здорово, когда в приложении все активити остаются висеть в памяти в активном состоянии.
            Плюс при повороте будет создаваться весь стек активити, и это заметно.
            Плюс отсутствие вызова онСтоп не позволяет впрямую обработать переход на новый экран (остановить какой-нибудь поллинг) — можно конечно свои методы для этого нагородить.


            Я понимаю, что сама задача требует активного предыдущего экрана под текущим, но в хорошей реализации должно быть учтено, что оставаться должен только ОДИН предыдущий экран.


            Есть компромиссное решение — делать скриншот экрана, но тут проблем еще больше, ибо довольно часто при переходе назад экран будет уже не соответствовать скриншоту


            1. forceLain
              01.06.2017 16:42

              все активити остаются висеть в памяти в активном состоянии.

              Почему все? Только активная и предыдущая
              Плюс при повороте будет создаваться весь стек активити

              Как и активных фрагментов
              и это заметно

              Если это заметно, скорее всего активити/фрагмент делают больше, чем им положено
              отсутствие вызова онСтоп не позволяет впрямую обработать переход на новый экран (остановить какой-нибудь поллинг)

              Для этого есть onPause
              Есть компромиссное решение — делать скриншот экрана

              Был и такой вариант :) Под кодовым названием План Y :)


              1. terrakok
                02.06.2017 01:25

                Почему все? Только активная и предыдущая

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


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


                А вообще хорошая статья, кому-то точно поможет разобраться)


      1. alexparshin
        01.06.2017 11:04

        Очевидно, что другого способа добиться требуемого эффекта с activity не существует, а программно этим управлять нельзя. В общем это я к тому, что c activity scroll-to-dismiss оптимально реализовать не получится.


  1. Remie_l
    01.06.2017 16:43

    Спасибо за статью, очень познавательно.
    Но не получается добавить затемнение.
    Может можно увидеть живой проект на гите?

    Вот кусок кода. У меня слайд вправо, так что код немного изменен.

    abstract class SlidingActivity : AppCompatActivity() {
    
       var windowScrim: ColorDrawable = ColorDrawable(Color.argb(0xE0, 0, 0, 0))
       <...>
       override fun onCreate(savedInstanceState: Bundle?) {
          super.onCreate(savedInstanceState)
          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             window.statusBarColor = Color.TRANSPARENT
          }
    
          windowScrim.alpha = 0
          window.setBackgroundDrawable(windowScrim)
       }
    
       private fun updateScrim() {
            Log.v(TAG, "updateScrim")
            val progress = root.x / screenSize.x
            val alpha = (progress * 255f).toInt()
            windowScrim.alpha = 255 - alpha
            this.window.setBackgroundDrawable(windowScrim)
        }
    
       override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
          <...>
          when (ev.action) {
             MotionEvent.ACTION_MOVE -> {
                if ((isSlidingRight(startX, startY, ev) && canSlideRight()) || isSliding) {
                      updateScrim()
                      <...>
                }
             }
          }
       }
    }
    


    Все работает отлично, при сдвиге updateScrim() в логе отрабатывается тоже, но затемнения не происходит.
    Может быть, я что-то упускаю?


    1. eastbanctech
      02.06.2017 07:02

      Добрый день! С виду все правильно. Нужно убедиться, что updateScrim() вызывается на каждое событие ACTION_MOVE и при завершающей анимации. В самой функции updateScrim() стоит проверить, что alpha действительно считается правильно и меняется от 0 до 255.
      На гитхабе, к сожалению, проекта нет, зато можем показать гист :) https://gist.github.com/forceLain/49e02e8d772e99707ebda9137b3b6ccb


      1. Remie_l
        02.06.2017 15:04

        Спасибо!
        Но даже полностью неизмененный код затемнение не делает.
        Видимо что-то глубже.
        Буду искать.


      1. Remie_l
        02.06.2017 15:40

        Разобралась. Моя ошибка была в том, что не тот view отправляла в обработку SlidingActivity:)


        1. eastbanctech
          06.06.2017 06:53

          рады, что все получилось)