CoordinatorLayout’s past life

Linkage effect

Modern Android development must be familiar with CoordinatorLayout, CoordinatorLayout + AppBarLayout + Toolbar CollapsingToolbarLayout + CollapsingToolbarLayout + CollapsingToolbarLayout + Toolbar

There are too many tutorials for this combination to be the focus of this article. When using XML, many of you must have missed a pitfall: the main content of the interface overlaps with the header element! Because CoordinatorLayout is like FrameLayout by default everything is on top of each other, and the solution is very metaphysical, Just add an app:layout_behavior=”@string/ APPbar_scrolling_view_behavior “to the content element. It’s like black magic!

Unfortunately, there is no magic in the code, we can be lazy because someone encapsulated it. Track into the string is com. Google. Android. Material. The appbar. AppBarLayout $ScrollingViewBehavior obviously this is a class! In fact, that’s the highlight of today’s show — Behavior.

This effect is so complicated that Google has wrapped it up for us. Here’s a simple example:

This is a copy of the Samsung One UI. Above is a head layout, and below is a RecyclerView. When sliding up, the first head layout shrinks and fades and has a parallax effect, and the RecyclerView is seamless after the head is completely hidden. Same thing with sliding down.

Event interception implementation

Before we go on, what if we don’t have CoordinatorLayout as a modern thing? Since this involves a combination of sliding gestures and View effects, the obvious place to start is with touch events. For simplicity’s sake, consider swiping your finger up (the list goes down to show more). Here’s what you need to do:

  1. In the parent layoutonInterceptTouchEventIntercept events in.
  2. The parent layoutonTouchEventHandle events, perform operations on the HeaderView (move, change transparency, etc.).
  3. HeaderView fully folded parent layout no longer intercepts events, RecyclerView normally handles sliding.

Now we have a problem. Because the parent layout blocks the event initially, the Android event distribution mechanism does not allow the child control to receive the event unless it is touched again, which makes the sliding between the two not seamless.

Then there is the problem of how to tell HeaderView to expand when RecyclerView slides down to the top.

Even if the above major problems are solved, there must be other glitches, such as child controls failing to trigger click events, which are annoying 💢. Assuming that you are the big guy to solve all the problems perfectly, certainly the coupling is particularly serious, and is a custom View is a mess of mutual reference 😵 so now do not go further, have leisure elegant capable students can try to achieve.

NestingScroll

Since Android 5.0 (API21), Google has provided an official solution – NestingScroll, which is a nested sliding mechanism used to coordinate the parent/child controls’ handling of sliding events. His basic idea is that the event is transmitted directly to the child control, by the child control to ask the parent control whether to slide, the parent control after processing to give the consumed distance, the child control continues to process the unconsumed distance. When the child control also slides to the top (bottom) the remaining distance to the parent control processing. Let me explain it vividly:

Son: Start sliding, ready to slide 300px, dad, do you want to slide first? Father: Ok, I will first slide 100px to the top, you continue. Son: Got it, I’m going to slide 160px to the bottom, dad, the rest is yours. Dad: Ok, there’s still 40px left, I’ll keep sliding (or I can ignore this callback)

In this way, the parent control did not intercept the event, but the child control after receiving the event actively asked, in their coordination with the completion of a seamless sliding connection. To achieve this, Google prepares two interfaces: NestedScrollingParent and NestedScrollingChild.

The main methods of NestedScrollingParent are as follows:

  • onStartNestedScroll : Boolean– Whether to consume this sliding event. (Dad, do you want to skate first?)
  • onNestedScrollAccepted– Confirm that the consumption slide callback can perform initialization work. (Ok, I’ll skate first.)
  • onNestedPreScroll– Callback before the child control handles the slide event. (I slid 100px first)
  • onNestedScroll– The callback after the child control slides can continue to perform the remaining distance. (and 40px, I keep sliding)
  • onStopNestedScroll– The event is over, and you can wrap things up.

Similarly, there are the Fling related interfaces.

The main methods of NestedScrollingChild are as follows:

  • startNestedScroll– Start sliding.
  • dispatchNestedPreScroll– Ask the parent component before swiping yourself.
  • dispatchNestedScroll– Notifies the parent of the remaining distance after the slide itself.
  • stopNestedScroll– End of slide.

And the Fling related interfaces and other things.

The final order of execution is as follows (the parent control accepts the event, the user triggers the throw) : Child startNestedScroll – father onStartNestedScroll – parent onNestedScrollAccepted | | onNestedPreScroll – child dispatchNestedPreScroll – father | | – child dispatchNestedScroll – father onNestedScroll | | – child dispatchNestedPreFling – father onNestedPreFling | | – child dispatchNestedFling – The father onNestedFling | | – child stopNestedScroll – parent onStopNestedScroll

RecyclerView has implemented Child interface by default, now as long as the outer layout to implement the Parent interface and make the correct response, should be able to achieve the purpose, the most troublesome event forwarding has been implemented in RecyclerView internal. But… Or do you need to define an external Layout? It still seems a little cumbersome and not completely decoupled.

Dangdang when! Behaviors!

CoordinatorLayout lives up to its name, it’s a layout that coordinates all the child views. Note the difference between NestedScrolling, which only schedules parent and child swipes, and NestedScrolling, which coordinates all child views. This eliminates the need for custom Layouts to implement nested sliding interfaces, and allows for more complex effects. CoordinatorLayout can only provide a platform, and the realization of specific effects depends on the Behavior. All the direct child controls of CoordinatorLayout can set the Behavior, which defines how the View should react to the touch event. Or how to react to changes in other views, successfully pulling the implementation out of the View.

CoordinatorLayout is like a central server for an online game. For nested slides, it implements the NestedScrollingParent interface so that it can receive slide information from child views, distribute it to all child views’ behaviors, and aggregate their responses back to the slide View. For functions that depend on other views, it notifies the Behavior of all child views that declare listening when a View property changes.

Note: Sliding events can be forwarded regardless of how many levels are nested. But only direct child views can set behaviors (responding to events) or be listened on.

In addition, the Behavior also has onInterceptTouchEvent and onTouchEvent methods, but the important point is that it receives more than its own scope of events. This means that the child View can now directly intercept events from the parent layout. Using this we can easily make drag-and-drop moves that other views follow, such as this:

Behavior acts as an aggregator for event handling, nested slide coordination, child control change listening, You can even modify the layout directly (onMeasureChild, onLayoutChild is the Behavior’s Child control). How does that work? Let’s look at the first example.

Actual combat: Imitation Of Samsung One UI

Post the effect picture again:

First look at the layout:

<?xml version="1.0" encoding="utf-8"? >
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:id="@+id/imagesTitleBlockLayout"
        android:layout_width="match_parent"
        android:layout_height="@dimen/title_block_height"
        android:gravity="center"
        android:orientation="vertical"
        app:layout_behavior=".ui.images.NestedHeaderScrollBehavior">

        <TextView
            style="@style/text_view_primary"
            android:text="@string/nav_menu_images"
            android:textSize="40sp" />

        <TextView
            android:id="@+id/imagesSubtitleTextView"
            style="@style/text_view_secondary"
            android:textSize="18sp"
            tools:text="183 images" />
    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/imagesRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior=".ui.images.NestedContentScrollBehavior"
        tools:listitem="@layout/rv_item_images_img" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Copy the code

In general, for the sake of simplicity, we select one View to respond to nested slides, and the other views listen to this View to synchronize changes. HeaderView is a little bit complicated and I don’t want it to do too much work, so I’m going to let RecyclerView take care of the nesting slide itself.

A big reason for this is that HeaderView has a parallax effect. Otherwise, let the HeaderView respond to sliding, RecyclerView only need to move close to the HeaderView, more simple.

Handles nested slides

Now write the Behavior required by RecyclerView. The first issue to be addressed is overlap, which requires the intervention layout mentioned earlier. The core idea is to get the height of HeaderView at the beginning, as the Top property of RecyclerView, you can achieve a similar LinearLayout.

Note: â‘  In order to set Behavior directly in XML, we need to write a constructor with the attrs argument.

indicates the type of View to which the Behavior is set, because there is no need to use the unique API of RecyclerView, so directly write View.

class NestedContentScrollBehavior(context: Context? , attrs: AttributeSet?) : CoordinatorLayout.Behavior<View>(context, attrs) {private var headerHeight = 0

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        // First let the parent layout parse in the standard way
        parent.onLayoutChild(child, layoutDirection)
        // Get the height of HeaderView
        headerHeight = parent.findViewById<View>(R.id.imagesTitleBlockLayout).height
        // Set top to be below HeaderView
        ViewCompat.offsetTopAndBottom(child, headerHeight)
        return true // true means we did the parsing ourselves and don't do it automatically}}Copy the code

Officially start nesting slide processing, first handle the finger up sliding situation. Since RecyclerView is only allowed to slide after the HeaderView has collapsed, it’s in the onNestedPreScroll method. If these slide callbacks are not clear look at the related section of NestingScroll in section 2 above.

    override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View,
                                     target: View, axes: Int, type: Int): Boolean {
        // If the slide is vertical, declare that it needs to be handled
        // Only if you return true here will you receive the following series of sliding events
        return(axes and ViewCompat.SCROLL_AXIS_VERTICAL) ! =0
    }

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        // RecyclerView has not yet started to slide
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        if (dy > 0) { // Handle only finger slippage
            val newTransY = child.translationY - dy
            if (newTransY >= -headerHeight) {
                // The sliding distance is completely consumed and the top is not completely or just stuck to the top
                // Then consume all the sliding distance and move up RecyclerView
                consumed[1] = dy // consumed[0/1] is used to state how much sliding distance is consumed in the X /y direction
                child.translationY = newTransY
            } else {
                // If completely consumed then RecyclerView will exceed the viewable area
                // Then just consume the distance of RecyclerView to the top
                consumed[1] = headerHeight + child.translationY.toInt()
                child.translationY = -headerHeight.toFloat()
            }
        }
    }
Copy the code

It’s not complicated. The core idea is to judge whether RecyclerView will exceed the window area after moving the distance of user request. If it does not exceed then all is consumed and the RV no longer slides on its own. If it goes beyond then only that part is consumed and the remaining distance slides inside the RV.

Then the writer points to the slippage. Because at this time, we need to give priority to RecyclerView sliding, and when it slides to the top, we need to move the whole down to make the HeaderView display, so we need to write in onNestedScroll.

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        // At this point the RV has finished sliding, dyUnconsumed indicates the remaining unconsumed sliding distance
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        if (dyUnconsumed < 0) { // Only swipe down
            val newTransY = child.translationY - dyUnconsumed
            if (newTransY <= 0) {
                child.= newTransY
            } else {
                child.translationY = 0f
            }
        }
    }
Copy the code

It’s a little bit easier than the last one. If the offset of the RV after sliding is less than 0 (Y offset <0 means moving up) then it is not completely back and consumes all remaining distance. Otherwise, just put the RV back in place.

Relationship between offsetTopAndBottom and translationY

In terms of usage, offsetTopAndBottom is usually used for permanent changes, and translationY is usually used for temporary changes (such as animations). We follow this convention here

In effect, offsetTopAndBottom(offset) is cumulative, internally equivalent to mTop+=offset, and translationY is reset every time regardless of the existing value.

Most importantly, onLayoutChild can be triggered multiple times, so the animation must be different from the way the layout is adjusted. Otherwise, you might have a slide in the middle of the execution that triggers a rearrangement, and the results automatically go back to where they are, and it just kind of bounces around visually.

Processing HeaderView

The main task is to listen for changes in RecyclerView to change the properties of HeaderView.

class NestedHeaderScrollBehavior constructor(context: Context? , attrs: AttributeSet?) : CoordinatorLayout.Behavior<View>(context, attrs) {override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        // child: The View associated with the current Behavior
        // dependency: Additional subviews to listen on to determine if needed
        return dependency.id == R.id.imagesRecyclerView
    }

    override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        child.translationY = dependency.translationY * 0.5f
        child.alpha = 1 + dependency.translationY / (child.height * 0.6f)
        // If the child size position is changed, it must return true to refresh
        return true}}Copy the code

This one is much easier. LayoutDependsOn triggers a View on each child View, using a method to determine if the View is listening on. If this View returns true, the View will receive a subsequent callback. In onDependentViewChanged, we calculate the bias and transparency of HeaderView based on the offset of RecyclerView. Parallax movement is achieved by multiplying by a coefficient.

So far the above effect has been basically achieved.

Surprise! Automatic homing

If the user drags a finger halfway up, leaving the UI in a half-collapsed state is not appropriate and should automatically fold or expand fully depending on the location.

Realize the idea is not difficult, listen to stop sliding events, judge the current RecyclerView offset, if more than half will be completely folded otherwise completely expanded. Here you need to animate with Scroller.

The Scroller is essentially a calculator that lets you figure out where you should be at any given moment by telling you the starting value, the amount of change, and the duration. You can also customize different easing effects. Smooth animation by constantly computing at high frequency constantly refreshing constantly moving.

The OverScroller incorporates all the features of the Scroller with additional features, so the Scroller is now deprecated.

Let us modify the corresponding NestedContentScrollBehavior RV.

    private lateinit var contentView: View // Create a RecyclerView
    private var scroller: OverScroller? = null
    private val scrollRunnable = object : Runnable {
        override fun run(a){ scroller? .let { scroller ->if (scroller.computeScrollOffset()) {
                    contentView.translationY = scroller.currY.toFloat()
                    ViewCompat.postOnAnimation(contentView, this)}}}}override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        contentView = child
        // ...
    }

    private fun startAutoScroll(current: Int, target: Int, duration: Int) {
        if (scroller == null) {
            scroller = OverScroller(contentView.context)
        }
        if(scroller!! .isFinished) { contentView.removeCallbacks(scrollRunnable) scroller!! .startScroll(0, current, 0, target - current, duration)
            ViewCompat.postOnAnimation(contentView, scrollRunnable)
        }
    }

    private fun stopAutoScroll(a){ scroller? .let {if(! it.isFinished) { it.abortAnimation() contentView.removeCallbacks(scrollRunnable) } } }Copy the code

Start by defining three variables and assigning them when appropriate. Explain scrollRunnable, how do I refresh the View after I get the different positions I should be in at different times? Because the sliding event has stopped, we don’t get any callbacks. Wang jinxi said No condition, create conditions and here by ViewCompat. PostOnAnimation let the View in the next map performs a defined as Runnable, inside the Runnable change the position of the View, If the animation isn’t over, submit a Runnable, which enables continuous refreshes. Write two more helper functions to start and stop the animation.

Now listen for the stop-sliding callback and start the animation as appropriate:

    override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
        super.onStopNestedScroll(coordinatorLayout, child, target, type)
        if (child.translationY >= 0f || child.translationY <= -headerHeight) {
            // The RV is back (fully folded or fully expanded)
            return
        }
        if (child.translationY <= -headerHeight * 0.5f) {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), -headerHeight, 1000)}else {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), 0.600)}}Copy the code

As a final refinement, stop the animation at the start of the slide so that the user doesn’t have to wait until the animation is over to slide again:

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        stopAutoScroll()
        // ...
    }

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        stopAutoScroll()
        // ...
    }
Copy the code

That’s perfect! Congratulations 🎉

reference

  • Play with Android nested scrolling
  • A bit of insight: Android nested sliders and NestedScrollView
  • Advanced series of custom View events