Inertial Sliding of android Custom Controls

Posted by sammon96 on Thu, 05 Sep 2019 06:17:17 +0200

Links to the original text: https://www.jianshu.com/p/57ce979b23e8

Reference: Inertial sliding of custom controls

Experience the sliding and scrolling source code of RecyclerView

I. Application scenarios

ScrollView is often used in customized views, but for some reason it is not possible to inherit ScrollView directly. It is necessary to see how they all scroll.
This paper only focuses on the effects of dragging and inertial sliding. Taking RecyclerView's code as an example (compared with ScrollView's implementation on scrolling, the implementation of inertial sliding is different from Interpolator's, as will be mentioned below), extract the code of finger dragging in RecyclerView and inertial sliding after finger leaving.

2. Effect demonstration

Inherit ViewGroup to achieve the sliding effect of RecyclerView, as shown in the figure:

Overview of Core Effects

  • Single finger drag
  • When multi-finger operation, drag with newly added finger as criterion
  • Inertial sliding when finger is loosened
  • Processing when sliding to the edge

IV. Effectiveness Realization

First, add 20 View s to onLayout to show the effect of dragging (this part is not related to sliding, but can be skipped for effect display). Here is the effect map:

There are 20 Items, which can only display the first two Items for the time being, because there is no sliding.

4.1 Single-fingered drag

For user operation, onTouchEvent() will be used naturally at this time, distinguishing user's pressing, moving and lifting operations.
Before that, we need to define a constant brightness mTouchSlop. When the finger moves more than this constant, it means that the finger starts to drag. Otherwise, it means that the finger just press, which can better distinguish the user's intentions.

final ViewConfiguration vc = ViewConfiguration.get(context);
mTouchSlop = vc.getScaledTouchSlop();

The code for onTouchEnevt() dragged by a single finger is shown below:

public static final int SCROLL_STATE_IDLE = 0;
public static final int SCROLL_STATE_DRAGGING = 1;
private int mLastTouchY;
    
@Override
public boolean onTouchEvent(MotionEvent event) {
    final int action = MotionEventCompat.getActionMasked(event);

    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            mScrollState = SCROLL_STATE_IDLE;
            mLastTouchY = (int) (event.getY() + 0.5f);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int y = (int) (event.getY() + 0.5f);
            int dy = mLastTouchY - y;

            if (mScrollState != SCROLL_STATE_DRAGGING) {
                boolean startScroll = false;

                if (Math.abs(dy) > mTouchSlop) {
                    if (dy > 0) {
                        dy -= mTouchSlop;
                    } else {
                        dy += mTouchSlop;
                    }
                    startScroll = true;
                }
                if (startScroll) {
                    mScrollState = SCROLL_STATE_DRAGGING;
                }
            }
            if (mScrollState == SCROLL_STATE_DRAGGING) {
                mLastTouchY = y;
                scrollBy(0, dy);
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            break;
        }
    }
    return true;
}

The above code and variables are all from the onTouchEvent method of RecyclerView, and of course I've excluded parts other than dragging with one finger. Explain the main ideas a little bit:
In user events: DOWN - > MOVE - > MOVE - > MOVE - > -> MOVE - > UP, first record the down position in DOWN, and calculate the position difference between DOWN and MOVE in each MOVE event. When one MOVE position difference is greater than the minimum moving distance (mTouchSlop), it indicates that the drag starts and the displacement begins. Later MOVE events do not need to be compared with mTouchSlop again, and drag the displacement directly until the UP event triggers.
At this time, there is a problem, as shown in the figure:

When there are two fingers, the first finger operation is the criterion. When the first finger is loosened, it will jump to the position when the second finger is pressed.

4.2 Multi-finger Operation

When referring to sliding, you need to specify who the control should listen to. There needs to be a constraint here:

  • The sliding of the newly added finger shall prevail.
  • When one finger is raised, the sliding of the remaining finger shall prevail.

To achieve the above constraints, it is inevitable to distinguish the finger on the screen. MotionEvent provides the getPointerId() method to return the ID of each finger.

The onTouchEvent method with multi-finger operation is added to the code.

private static final int INVALID_POINTER = -1;
private int mScrollPointerId = INVALID_POINTER;
@Override
public boolean onTouchEvent(MotionEvent event) {
    final int action = MotionEventCompat.getActionMasked(event);
    final int actionIndex = MotionEventCompat.getActionIndex(event);

    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            setScrollState(SCROLL_STATE_IDLE);
            mScrollPointerId = event.getPointerId(0);
            mLastTouchY = (int) (event.getY() + 0.5f);
            break;
        }
        case MotionEventCompat.ACTION_POINTER_DOWN: {
            mScrollPointerId = event.getPointerId(actionIndex);
            mLastTouchY = (int) (event.getY(actionIndex) + 0.5f);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            final int index = event.findPointerIndex(mScrollPointerId);
            if (index < 0) {
                Log.e("zhufeng", "Error processing scroll; pointer index for id " + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                return false;
            }

            final int y = (int) (event.getY(index) + 0.5f);
            int dy = mLastTouchY - y;

            if (mScrollState != SCROLL_STATE_DRAGGING) {
                boolean startScroll = false;

                if (Math.abs(dy) > mTouchSlop) {
                    if (dy > 0) {
                        dy -= mTouchSlop;
                    } else {
                        dy += mTouchSlop;
                    }
                    startScroll = true;
                }
                if (startScroll) {
                    setScrollState(SCROLL_STATE_DRAGGING);
                }
            }

            if (mScrollState == SCROLL_STATE_DRAGGING) {
                mLastTouchY = y;
                scrollBy(0, dy);
            }
            break;
        }
        case MotionEventCompat.ACTION_POINTER_UP: {
            if (event.getPointerId(actionIndex) == mScrollPointerId) {
                // Pick a new pointer to pick up the slack.
                final int newIndex = actionIndex == 0 ? 1 : 0;
                mScrollPointerId = event.getPointerId(newIndex);
                mLastTouchY = (int) (event.getY(newIndex) + 0.5f);
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            break;
        }
    }
    return true;
}

A new variable mScrollPointerId is added to specify which finger operation the current movement follows. When a new finger is added, mScrollPointerId is set as the new finger. When a finger leaves, set mScrollPointerId to the remaining finger.
Two events, ACTION_POINTER_DOWN and ACTION_POINTER_UP, were added. After the existing DOWN events, the new finger click will start ACTION_POINTER_DOWN events. ACTION_POINTER_DOWN and ACTION_POINTER_UP are similar to DOWN and UP events, and both occur in pairs. The difference is that DOWN and UP are the first fingers. ACTION_POINTER_DOWN and ACTION_POINTER_UP trigger once a new finger is added.
The core is to identify the actual operation of the finger (mScrollPointerId). The accuracy of displacement information can be guaranteed by using the finger of mScrollPointerId to calculate the location information.
To give a proper effect:

4.3 Inertial Sliding

To achieve inertial sliding, we need to do:

  • Get the speed at which your fingers are lifted
  • Converting velocity to specific displacement

4.3.1 Acquisition Speed

First, about how to get speed in ACTION_UP. The getYVelocity of Velocity TrackerCompat can obtain the speed of the finger with the specified ID on the current Y axis. Negative upward and positive downward. Regarding the use of Velocity TrackerCompat and Velocity Tracker, here is a direct post:

private VelocityTracker mVelocityTracker;

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (mVelocityTracker == null) {
        mVelocityTracker = VelocityTracker.obtain();
    }
    boolean eventAddedToVelocityTracker = false;
    final MotionEvent vtev = MotionEvent.obtain(event);
    ...
    case MotionEvent.ACTION_UP: {
        mVelocityTracker.addMovement(vtev);
        eventAddedToVelocityTracker = true;
        mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
        float yVelocity = -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId);
        ...
    }
    if (!eventAddedToVelocityTracker) {
        mVelocityTracker.addMovement(vtev);
    }
    vtev.recycle();
    ...
}

4.3.2 Responses velocity to sliding

Focus on how to reflect the speed of UP to the scroll of the control. According to OverScroller's fling method, we don't explore how this method is implemented. Just know that after calling this method, we can call getCurrY() method continuously, ask where we are currently moving, and know that the computeScrollOffset method returns false.
So what we have to do is:

  1. Acquire the moving speed on Y axis when UP
  2. Inertial sliding is needed in judgment
  3. Flying method of OverScroller is called to simulate sliding when inertial sliding is needed.
  4. Before the sliding stops, keep asking where to slide to set the position of the control according to the calculation.

It needs a clear concept, OverScroller method, only involves the calculation of sliding position. According to the input value, it calculates when and where to slide. The movement of specific control still needs to call ScrollTo or ScrollBy method of View.

Implementing it into the code is:

private class ViewFlinger implements Runnable {

    private int mLastFlingY = 0;
    private OverScroller mScroller;
    private boolean mEatRunOnAnimationRequest = false;
    private boolean mReSchedulePostAnimationCallback = false;

    public ViewFlinger() {
        mScroller = new OverScroller(getContext(), sQuinticInterpolator);
    }

    @Override
    public void run() {
        disableRunOnAnimationRequests();
        final OverScroller scroller = mScroller;
        if (scroller.computeScrollOffset()) {
            final int y = scroller.getCurrY();
            int dy = y - mLastFlingY;
            mLastFlingY = y;
            scrollBy(0, dy);
            postOnAnimation();
        }
        enableRunOnAnimationRequests();
    }

    public void fling(int velocityY) {
        mLastFlingY = 0;
        setScrollState(SCROLL_STATE_SETTLING);
        mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        postOnAnimation();
    }

    public void stop() {
        removeCallbacks(this);
        mScroller.abortAnimation();
    }

    private void disableRunOnAnimationRequests() {
        mReSchedulePostAnimationCallback = false;
        mEatRunOnAnimationRequest = true;
    }

    private void enableRunOnAnimationRequests() {
        mEatRunOnAnimationRequest = false;
        if (mReSchedulePostAnimationCallback) {
            postOnAnimation();
        }
    }

    void postOnAnimation() {
        if (mEatRunOnAnimationRequest) {
            mReSchedulePostAnimationCallback = true;
        } else {
            removeCallbacks(this);
            ViewCompat.postOnAnimation(CustomScrollView.this, this);
        }
    }
}

The code used here is inertial sliding in RecyclerView, eliminating some unnecessary parts.

As can be seen from the public void fling (int velocity Y) method, it starts with (0, 0) coordinates. Indicates that wherever the current View moves, Scroller here calculates where it should move if it starts at (0, 0), depending on the speed at which the parameters are passed.

There is also a ViewCompat.postOnAnimation(view, runable); this sentence is equivalent to view.postDelayed(runable, 10);.

4.3.3 Interpolator

An sQuintic Interpolator is used to initialize OverScroller. Specific definitions are as follows:

//f(x) = (x-1)^5 + 1
private static final Interpolator sQuinticInterpolator = new Interpolator() {
    @Override
    public float getInterpolation(float t) {
        t -= 1.0f;
        return t * t * t * t * t + 1.0f;
    }
};

A custom interpolator is used here. As mentioned above, part of the difference between RecyclerView and ScrollView in inertial sliding is that RecyclerView uses this custom interpolator and ScrollView uses the default Scroller. Viscous Fluid Interpolator.

One of the intuitive effects of the interpolator is the inertial sliding of RecyclerView. That is to say, it starts very fast, then slows down slowly until it stops.

The main method of differentiator is getInterpolation(float t). The parameter t is the percentage of gliding time, from 0 to 1. The return value is the percentage of the glide distance, which can be less than 0 and greater than 1.

Take RecyclerView's interpolator for example. If the finger is lifted according to the speed, it will eventually take 5 seconds to slide 1000 pixels. According to the sQuintic Interpolator interpolator above, when sliding for 2 seconds, the value of t is 2/5 = 0.4. getInterpolation(0.4)=0.92 indicates that 0.92 * 1000 = 920 pixels have been slid. More intuitive can be expressed by the curve of the interpolator on [0,1]:

The abscissa represents the time and the ordinate represents the percentage of the total distance completed. As shown in the figure, the interpolator of RecyclerView has the effect of completing most of the journeys in a very short time. Formally, we see a very rapid early, then very slow effect.

Also attached is the graph of Scroller.Viscous Fluid Interpolator on [0,1]:

It doesn't make much difference.

4.4 Edge Processing

When sliding to the upper and lower sides, it can still slide. It is inappropriate and needs to be constrained. Just paste the code directly:

private void constrainScrollBy(int dx, int dy) {
    Rect viewport = new Rect();
    getGlobalVisibleRect(viewport);
    int height = viewport.height();
    int width = viewport.width();

    int scrollX = getScrollX();
    int scrollY = getScrollY();

    //Right boundary
    if (mWidth - scrollX - dx < width) {
        dx = mWidth - scrollX - width;
    }
    //Left boundary
    if (-scrollX - dx > 0) {
        dx = -scrollX;
    }
    //Lower boundary
    if (mHeight - scrollY - dy < height) {
        dy = mHeight - scrollY - height;
    }
    //Upper Boundary
    if (scrollY + dy < 0) {
        dy = -scrollY;
    }
    scrollBy(dx, dy);
}

Change scrollBy in the code to constrainScrollBy() with additional constraints.

5. Give the source code of custom View

package com.rajesh.scrolldemo;

import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.support.v4.view.MotionEventCompat;
import android.support.v4.view.VelocityTrackerCompat;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.widget.OverScroller;
import android.widget.TextView;

/**
 * Created by zhufeng on 2017/7/26.
 */

public class CustomScrollView extends ViewGroup {
    private Context mContext;
    private int SCREEN_WIDTH = 0;
    private int SCREEN_HEIGHT = 0;
    private int mWidth = 0;
    private int mHeight = 0;
    private static final int INVALID_POINTER = -1;
    public static final int SCROLL_STATE_IDLE = 0;
    public static final int SCROLL_STATE_DRAGGING = 1;
    public static final int SCROLL_STATE_SETTLING = 2;

    private int mScrollState = SCROLL_STATE_IDLE;
    private int mScrollPointerId = INVALID_POINTER;
    private VelocityTracker mVelocityTracker;
    private int mLastTouchY;
    private int mTouchSlop;
    private int mMinFlingVelocity;
    private int mMaxFlingVelocity;
    private final ViewFlinger mViewFlinger = new ViewFlinger();

    //f(x) = (x-1)^5 + 1
    private static final Interpolator sQuinticInterpolator = new Interpolator() {
        @Override
        public float getInterpolation(float t) {
            t -= 1.0f;
            return t * t * t * t * t + 1.0f;
        }
    };

    public CustomScrollView(Context context) {
        this(context, null);
    }

    public CustomScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        init(context);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int top = 0;
        for (int i = 0; i < 20; i++) {
            int width = SCREEN_WIDTH;
            int height = SCREEN_HEIGHT / 2;
            int left = 0;
            int right = left + width;
            int bottom = top + height;

            //Strengthen borders
            if (bottom > mHeight) {
                mHeight = bottom;
            }
            if (right > mWidth) {
                mWidth = right;
            }

            TextView textView = new TextView(mContext);
            if (i % 2 == 0) {
                textView.setBackgroundColor(Color.CYAN);
            } else {
                textView.setBackgroundColor(Color.GREEN);
            }
            textView.setText("item:" + i);
            addView(textView);
            textView.layout(left, top, right, bottom);
            top += height;
            top += 20;
        }
    }

    private void init(Context context) {
        this.mContext = context;
        final ViewConfiguration vc = ViewConfiguration.get(context);
        mTouchSlop = vc.getScaledTouchSlop();
        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
        mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
        DisplayMetrics metric = context.getResources().getDisplayMetrics();
        SCREEN_WIDTH = metric.widthPixels;
        SCREEN_HEIGHT = metric.heightPixels;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        boolean eventAddedToVelocityTracker = false;
        final int action = MotionEventCompat.getActionMasked(event);
        final int actionIndex = MotionEventCompat.getActionIndex(event);
        final MotionEvent vtev = MotionEvent.obtain(event);

        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                setScrollState(SCROLL_STATE_IDLE);
                mScrollPointerId = event.getPointerId(0);
                mLastTouchY = (int) (event.getY() + 0.5f);
                break;
            }
            case MotionEventCompat.ACTION_POINTER_DOWN: {
                mScrollPointerId = event.getPointerId(actionIndex);
                mLastTouchY = (int) (event.getY(actionIndex) + 0.5f);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                final int index = event.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    Log.e("zhufeng", "Error processing scroll; pointer index for id " + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                    return false;
                }

                final int y = (int) (event.getY(index) + 0.5f);
                int dy = mLastTouchY - y;

                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    boolean startScroll = false;

                    if (Math.abs(dy) > mTouchSlop) {
                        if (dy > 0) {
                            dy -= mTouchSlop;
                        } else {
                            dy += mTouchSlop;
                        }
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }

                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mLastTouchY = y;
                    constrainScrollBy(0, dy);
                }

                break;
            }
            case MotionEventCompat.ACTION_POINTER_UP: {
                if (event.getPointerId(actionIndex) == mScrollPointerId) {
                    // Pick a new pointer to pick up the slack.
                    final int newIndex = actionIndex == 0 ? 1 : 0;
                    mScrollPointerId = event.getPointerId(newIndex);
                    mLastTouchY = (int) (event.getY(newIndex) + 0.5f);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                mVelocityTracker.addMovement(vtev);
                eventAddedToVelocityTracker = true;
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                float yVelocity = -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId);
                Log.i("zhufeng", "Velocity value:" + yVelocity);
                if (Math.abs(yVelocity) < mMinFlingVelocity) {
                    yVelocity = 0F;
                } else {
                    yVelocity = Math.max(-mMaxFlingVelocity, Math.min(yVelocity, mMaxFlingVelocity));
                }
                if (yVelocity != 0) {
                    mViewFlinger.fling((int) yVelocity);
                } else {
                    setScrollState(SCROLL_STATE_IDLE);
                }
                resetTouch();
                break;
            }
            case MotionEvent.ACTION_CANCEL: {
                resetTouch();
                break;
            }
        }
        if (!eventAddedToVelocityTracker) {
            mVelocityTracker.addMovement(vtev);
        }
        vtev.recycle();
        return true;

    }

    private void resetTouch() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
    }

    private void setScrollState(int state) {
        if (state == mScrollState) {
            return;
        }
        mScrollState = state;
        if (state != SCROLL_STATE_SETTLING) {
            mViewFlinger.stop();
        }
    }

    private class ViewFlinger implements Runnable {

        private int mLastFlingY = 0;
        private OverScroller mScroller;
        private boolean mEatRunOnAnimationRequest = false;
        private boolean mReSchedulePostAnimationCallback = false;

        public ViewFlinger() {
            mScroller = new OverScroller(getContext(), sQuinticInterpolator);
        }

        @Override
        public void run() {
            disableRunOnAnimationRequests();
            final OverScroller scroller = mScroller;
            if (scroller.computeScrollOffset()) {
                final int y = scroller.getCurrY();
                int dy = y - mLastFlingY;
                mLastFlingY = y;
                constrainScrollBy(0, dy);
                postOnAnimation();
            }
            enableRunOnAnimationRequests();
        }

        public void fling(int velocityY) {
            mLastFlingY = 0;
            setScrollState(SCROLL_STATE_SETTLING);
            mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            postOnAnimation();
        }

        public void stop() {
            removeCallbacks(this);
            mScroller.abortAnimation();
        }

        private void disableRunOnAnimationRequests() {
            mReSchedulePostAnimationCallback = false;
            mEatRunOnAnimationRequest = true;
        }

        private void enableRunOnAnimationRequests() {
            mEatRunOnAnimationRequest = false;
            if (mReSchedulePostAnimationCallback) {
                postOnAnimation();
            }
        }

        void postOnAnimation() {
            if (mEatRunOnAnimationRequest) {
                mReSchedulePostAnimationCallback = true;
            } else {
                removeCallbacks(this);
                ViewCompat.postOnAnimation(CustomScrollView.this, this);
            }
        }
    }

    private void constrainScrollBy(int dx, int dy) {
        Rect viewport = new Rect();
        getGlobalVisibleRect(viewport);
        int height = viewport.height();
        int width = viewport.width();

        int scrollX = getScrollX();
        int scrollY = getScrollY();

        //Right boundary
        if (mWidth - scrollX - dx < width) {
            dx = mWidth - scrollX - width;
        }
        //Left boundary
        if (-scrollX - dx > 0) {
            dx = -scrollX;
        }
        //Lower boundary
        if (mHeight - scrollY - dy < height) {
            dy = mHeight - scrollY - height;
        }
        //Upper Boundary
        if (scrollY + dy < 0) {
            dy = -scrollY;
        }
        scrollBy(dx, dy);
    }
}

 

 

 

 

 

 

Topics: Android less