Привет! Сегодня мы расскажем, как за минимальное количество времени добавить в свою 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)
alexparshin
01.06.2017 06:36Атрибут android:windowIsTranslucent приведет к тому, что перекрываемая activity не перейдет в stopped состояние.
eastbanctech
01.06.2017 06:46Добрый день! Именно для этого мы его и написали — чтобы задняя activity была видна в любой момент времени.
Tusur
01.06.2017 07:23По этому наверно правильнее делать на фрагментах, так как они в любом случае остаются видимыми.
forceLain
01.06.2017 07:37так как они в любом случае остаются видимыми
Это зависит от того, как мы совершаем транзакцию: add или replace. Replace нам удалит предыдущий фрагмент с экрана, так что он тоже не будет видимым и работать это не будет. Add добавит фрагмент поверх предыдущего, однако предыдущий фрагмент не получит onPause()/onResume() колбэков, в отличие от примера с activity. Если использовать подход с фрагментами, то лучше сразу использовать BottomSheetFragmentDialog из дизайнерской либы.
Я бы сказал, что в общем случае лучше руководствоваться здравым смыслом. Если контент фрагментов слишком разный (стили, цвета, набор иконок в тулбаре, наличие и отсутствие бокового меню, интеграция с context), то объединение их в одну активити ради scroll-to-dismiss принесет больше проблем, чем профита.terrakok
01.06.2017 12:21Но активити сама по себе как системный класс — тяжелее фрагмента. совсем не здорово, когда в приложении все активити остаются висеть в памяти в активном состоянии.
Плюс при повороте будет создаваться весь стек активити, и это заметно.
Плюс отсутствие вызова онСтоп не позволяет впрямую обработать переход на новый экран (остановить какой-нибудь поллинг) — можно конечно свои методы для этого нагородить.
Я понимаю, что сама задача требует активного предыдущего экрана под текущим, но в хорошей реализации должно быть учтено, что оставаться должен только ОДИН предыдущий экран.
Есть компромиссное решение — делать скриншот экрана, но тут проблем еще больше, ибо довольно часто при переходе назад экран будет уже не соответствовать скриншоту
forceLain
01.06.2017 16:42все активити остаются висеть в памяти в активном состоянии.
Почему все? Только активная и предыдущая
Плюс при повороте будет создаваться весь стек активити
Как и активных фрагментов
и это заметно
Если это заметно, скорее всего активити/фрагмент делают больше, чем им положено
отсутствие вызова онСтоп не позволяет впрямую обработать переход на новый экран (остановить какой-нибудь поллинг)
Для этого есть onPause
Есть компромиссное решение — делать скриншот экрана
Был и такой вариант :) Под кодовым названием План Y :)terrakok
02.06.2017 01:25Почему все? Только активная и предыдущая
если открыть третье активити с возможностью закрытия свайпом, то и первое и второе и третье будут активными. и так далее.
Активити создаются заметно дольше фрагментов. Никаких тяжелых операций в них запускать для этого не надо, достаточно взять не самый топовый девайс и можно определять на глаз, где фрагменты, а где активити.
А вообще хорошая статья, кому-то точно поможет разобраться)
alexparshin
01.06.2017 11:04Очевидно, что другого способа добиться требуемого эффекта с activity не существует, а программно этим управлять нельзя. В общем это я к тому, что c activity scroll-to-dismiss оптимально реализовать не получится.
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() в логе отрабатывается тоже, но затемнения не происходит.
Может быть, я что-то упускаю?eastbanctech
02.06.2017 07:02Добрый день! С виду все правильно. Нужно убедиться, что updateScrim() вызывается на каждое событие ACTION_MOVE и при завершающей анимации. В самой функции updateScrim() стоит проверить, что alpha действительно считается правильно и меняется от 0 до 255.
На гитхабе, к сожалению, проекта нет, зато можем показать гист :) https://gist.github.com/forceLain/49e02e8d772e99707ebda9137b3b6ccbRemie_l
02.06.2017 15:04Спасибо!
Но даже полностью неизмененный код затемнение не делает.
Видимо что-то глубже.
Буду искать.
Remie_l
02.06.2017 15:40Разобралась. Моя ошибка была в том, что не тот view отправляла в обработку SlidingActivity:)
IgeNiaI
Спасибо, интересная реализация.
Думаю, было бы неплохо такую фишку стандартизировать, чтобы пользователям не нужно было запоминать свайпы для каждого приложения. Желательно в виде библиотеки, где разработчикам всего то и нужно, что наследовать свои Activity от вашей реализации.
Ну и еще я думаю, что свайп слева как в iOS был бы лучше, чем сверху, так как достать туда проще. Да и кнопка и стрелка «назад» показывают именно влево.
IgeNiaI
А, ну еще так как большинство приложений являются по сути вертикальными списками с различным контентом, то свайп сверху действительно неуместен в большинстве случаев.
forceLain
Конкретно в этой реализации необязательно тянуть палец до самого заголовка. Свайп можно начать с любого места.