Привет! Сегодня мы расскажем, как за минимальное количество времени добавить в свою 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.
Пользуйтесь на здоровье!
Автор: eastbanctech