📜 ⬆️ ⬇️

Recipes for Android: Scroll-To-Dismiss Activity

Hello! Today we will tell how to add the Scroll-To-Dismiss behavior to your Activity in the minimum amount of time. Scroll-To-Dismiss is a popular gesture in the modern world that allows you to close the current screen and return to the previous Activity.



One day, we received a request to add such functionality to one of our news apps. If you are interested in how easy it is to add such functionality to an existing Activity and avoid possible problems - welcome under cat.


What do we have?


The solution "in the forehead" is quite obvious: use one Activity and a couple of fragments, the position of which can be adjusted within one Activity.


Such an approach caused some doubts for us, since the application already had existing navigation: a separate Activity for the news list and a separate Activity for reading the article itself.


In spite of the fact that the functional list of articles and reading of the article were already decomposed into separate corresponding fragments, this did not save. Since the fragments themselves required that the Host hosting them have a specific interface and implementation (as is usually the case with fragments). In addition, the UI of these screens was quite different: a different set of menu buttons, a different behavior of the toolbar (Behavior).


In total, this all made the combination of two screens into one for the sake of one designer tweak irrational.




The navigation pattern itself, as already mentioned, is quite popular. So it is no wonder that the Android API already has some possibilities for its implementation. In addition to the already voiced "head-on" solution, one could use:



Unfortunately, they also did not suit us, because either they require a custom layout wrapper, whose behavior conflicts with the behavior of internal components, or they are too closed to configure the API.


Movement is life


Changing the position of the Activity we do not really succeed, but we can move its content. We will monitor the user's finger movement and change the coordinates of the topmost container in the hierarchy accordingly. Let's make a base class that can be reused for any Activity.


Code samples will be on Kotlin, because it is smaller :)


abstract class SlidingActivity : AppCompatActivity() { private lateinit var root: View override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) root = getRootView() //        } abstract fun getRootView(): View } 

Next, learn to listen and respond to user gestures. It would be possible to wrap the root element in your container and track actions in it, but we will go the other way. In Activity, you can override the dispatchTouchEvent(...) method, which is the first screen touch handler. You can see the processor handler below:


 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) } } 

It is not difficult to understand that the user leads the finger from top to bottom (to “flick off” the screen): the y coordinate of all events following the initial position increases, and x can fluctuate in some minor interval. With this problem, as a rule, does not arise. Problems begin when there are other scrollable elements on the screen: ViewPager, RecyclerView, Toolbar with some Behavior, you should always keep them in mind:


 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 } } 

Notice that we have added a new abstract method canSlideDown() : Boolean . To them, we ask the heir if the current moment is appropriate to start our Scroll-ToDismiss gesture. For example, if a user reads an article and is somewhere in the middle of the text, then with a finger gesture from top to bottom, he probably wants to scroll the article higher, instead of closing the entire screen.


The second important point is the fact that our handler stops sending events further along the chain (no super.dispatchTouchEvent(ev) ) from the moment the gesture we needed determined. This is necessary to ensure that all nested scrollable widgets stop responding to finger movements and move content independently. Before cutting the processing chain, we send MotionEvent.ACTION_CANCEL so that the nested elements do not view the suddenly interrupted message flow as a "Long Click".



We bring to readiness


When the user raised his finger, and we realized that the screen can be closed, we can not call Activity.finish() at the same time. More precisely, we can, of course, but it will look like a screen that suddenly closed. What we need to do is to animate the root container down the screen and after that close the 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() } 

The last thing left for us is to make our Activity transparent so that when we brush it, we can see the screen that it covers. To achieve this effect, simply add the following attributes to the theme of your 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> 

To make Scroll-To-Dismiss look even cooler, you can add the effect of darkening the back screen as you scroll:


 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 } 

As the root container moves (finger or animation), just call updateScrim() and the background will change dynamically.


Total


In this rather simple way, we got not only the required behavior, but also the ability to flexibly influence behavior.


For example, if you wish, you can teach our Activity to be brushed up or sideways. Gestures intercepted at the Activity level do not break the behavior of internal components, such as ViewPager, RecyclerView, and even AppbarLayout + Custom Behavior.


Use on health!


')

Source: https://habr.com/ru/post/329896/


All Articles