A traceless transition pull – down refresh control implementation ideas

I believe that we have been familiar with the drop-down refresh can not be familiar with the market, the drop-down refresh is full of beautiful things in eyes, but there are a lot of defects in my opinion, next I will explain the existence of defects, and then provide a way to solve this defect, nonsense not to say! Look down!

1. Some pulldown refresh controls in the market are common defects demonstration

Take live-streaming APP as an example:

Case 1:

When the slide control is at the initial position 0, the gesture slides down and then slides up again. It can be seen that the slide control cannot slide when it reaches the initial position.

Cause: The drop-down refresh control responds to the touch event, and the subsequent series of events are processed by it. When the slide control reaches the top end, the slide events are consumed by the drop-down refresh control, and its child control, namely the slide control, cannot be passed, so the slide control cannot slide.





Case 2:

When the slide control slides to a non-zero position, when it is pulled back to zero, you can see that the drop-down refresh header is not pulled out.

Cause: The slide control responds to the touch event, and the subsequent series of events are processed by it. When the slide control reaches the top, the slide events are consumed by the slide control. The parent control, namely the drop-down refresh control, cannot consume the slide event, so the drop-down refresh head is not pulled out.





Most people may think it is not painful, just lift the finger and then pull down, but for me with obsessive-compulsive disorder, it is the most logical operation to provide a traceless transition, so I will explain the idea of implementation next.

2. Implementation of the train of thought

2.1. Introduction to Event Distribution mechanism (From Android Development Art Exploration)

Relational pseudocode for the dispatchTouchEvent, onInterceptTouchEvent, and onTouchEvent methods

public boolean dispatchTouchEvent(MotionEvent ev) { 
    boolean consume = false;
    if(onInterceptTouchEvent(ev)) { 
        consume = onTouchEvent(ev);
    } else { 
        consume = child.dispatchTouchEvent(ev); 
    }
    return consume; 
}Copy the code

1. According to the code, if the current View intercepts the event, it will give its own onTouchEvent to handle, otherwise it will throw the child View to continue the same process. 2. Event delivery sequence: Activity -> Window -> View. If the View is not processed, it will be processed by the Activity’s onTouchEvent, which is a responsibility chain mode implementation. 3. Normally, a sequence of events can only be intercepted and consumed by one View. 4. Once a View decides to intercept, the sequence of events can only be handled by it, and its onInterceptTouchEvent is never called again. Without consuming ACTION_DOWN, the sequence of events is handled by its parent element.

2.2. Conjecture of the implementation idea of general pull-down refresh

The onInterceptTouchEvent and onTouchEvent methods need to be overwritten. Then, the onInterceptTouchEvent method determines the ACTION_DOWN event based on the sliding distance of the child control. If not, onInterceptTouchEvent returns true to indicate that it intercepts the event, and then the onTouchEvent dropdown refreshes the header to show hidden logic processing. If the child control slides over and does not intercept the event, onInterceptTouchEvent returns false, and its subsequent drop refresh header shows hidden logic processing that cannot be called.

2.3. The implementation idea of the traceless transition drop down refresh control

As you can see from 2.2, for traceless transitions, the pull-down refresh control cannot intercept events. In this case, you may ask, how can the pull-down refresh header logic be implemented after the event is given to a child control?

This method returns true by default in ViewGroup to send the event. Even if the child controls intercept the event, the parent layout’s dispatchTouchEvent will still be called because the event is passed down. This method must be called.

So we can determine the sliding distance of the child control at dispatchTouchEvent, where we remove the logic of the pull-down refresh header, and at the same time in the function callreturn super.dispatchTouchEvent(event)Set the action of the event to ACTION_CANCEL so that the child control will not respond to sliding.

3. Code implementation

3.1. Identify requirements

  • Need to adapt any controls, such as RecyclerView, ListView, ViewPager, WebView and ordinary can not slide View
  • The original event logic of the child control cannot be affected
  • Expose methods provide manual call refresh capabilities
  • You can disable the drop-down refresh function

3.2. Code explanation

Required variables

public class RefreshLayout extends LinearLayout {
    // The hidden state
    private static final int HIDE = 0;
    // Pull-down refresh status
    private static final int PULL_TO_REFRESH = 1;
    // Release the refresh state
    private static final int RELEASE_TO_REFRESH = 2;
    // The state is being refreshed
    private static final int REFRESHING = 3;
    // The state is being hidden
    private static final int HIDING = 4;
    // Current status
    private int mCurrentState = HIDE;

    // The default time for the header animation (in milliseconds)
    public static final int DEFAULT_DURATION = 200;

    // Head height
    private int mHeaderHeight;
    // The sliding distance of the content control
    private int mContentViewOffset;
    // Minimum sliding response distance
    private int mScaledTouchSlop;
    // Record the last Y coordinate
    private float mLastMotionY;
    // Record the initial Y coordinate
    private float mInitDownY;
    // The response finger
    private int mActivePointerId;

    // Whether the header is being processed
    private boolean mIsHeaderHandling;
    // Whether the refresh can be pulled down
    private boolean mIsRefreshable = true;

    // Whether the content control can slide, controls that cannot slide will be optimized for touch events
    private boolean mContentViewScrollable = true;
    // Header, selected TextView for demonstration purposes
    private TextView mHeader;

    // The content controls to be hosted by the container are placed inside the XML
    private View mContentView;

    // Value animation, because the header display is hidden
    private ValueAnimator mHeaderAnimator;

    // Refresh the listener
    private OnRefreshListener mOnRefreshListener;
Copy the code

Create headers perform animations to show hidden values at initialization, add headers to the layout, and hide headers by setting paddingTop

    public RefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
        addHeader(context);
    }

    private void init() {

        mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();

        mHeaderAnimator = ValueAnimator.ofInt(0).setDuration(DEFAULT_DURATION);
        mHeaderAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                if (getContext() == null) {
                    // If you exit the Activity, the animation ends without performing the head action
                    return;
                }
                // Set paddingTop to show or hide headers
                int offset = (Integer) valueAnimator.getAnimatedValue();
                mHeader.setPadding(0, offset, 0.0); }}); mHeaderAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (getContext() == null) {
                    // If you exit the Activity, the animation ends without performing the head action
                    return;
                }
                if (mCurrentState == RELEASE_TO_REFRESH) {
                    // The animation that releases the refresh state is finished, which means that the next step is to refresh, change the state and call the refresh listener
                    mHeader.setText("Refreshing...");
                    mCurrentState = REFRESHING;
                    if(mOnRefreshListener ! =null) { mOnRefreshListener.onRefresh(); }}else if (mCurrentState == HIDING) {
                    // The animation executed in the pull-down state ends, hides the head, and changes the state
                    mHeader.setText("I am the head"); mCurrentState = HIDE; }}}); }// Create a header
    private void addHeader(Context context) {

        // Enforce the vertical method
        setOrientation(LinearLayout.VERTICAL);

        mHeader = new TextView(context);
        mHeader.setBackgroundColor(Color.GRAY);
        mHeader.setTextColor(Color.WHITE);
        mHeader.setText("I am the head");
        mHeader.setTextSize(TypedValue.COMPLEX_UNIT_SP, 25);
        mHeader.setGravity(Gravity.CENTER);
        addView(mHeader, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);

        mHeader.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // Calculate the head height
                mHeaderHeight = mHeader.getMeasuredHeight();
                // Remove the listener
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    mHeader.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                } else {
                    mHeader.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                }
                // Set paddingTop to -mheaderHeight, just to hide the header
                mHeader.setPadding(0, -mHeaderHeight, 0.0); }}); }Copy the code

Extract the content control after filling the layout

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // Set long clicks and short clicks to consume events, otherwise, if the child does not consume, eventually the click event will be consumed by its parent, the following series of events will only be processed by its parent
        setLongClickable(true);

        // Get the content control
        mContentView = getChildAt(1);
        if (mContentView == null) {
            // Is a null-throw exception that forces the content control to be set in XML
            throw new IllegalArgumentException("You must add a content view!");
        }
        if(! (mContentViewinstanceof ScrollingView 
             || mContentView instanceof WebView 
              || mContentView instanceof ScrollView 
               || mContentView instanceof AbsListView)) {
            // This is not a scroll control
            mContentViewScrollable = false; }}Copy the code

1. MContentViewOffset is used to determine the sliding distance of the content page, and only handles the pull-down refresh when there is no offset value; 2. In mContentViewOffset!!! =0 is the first moment when the content page slides, forcibly change the MOVE event to DOWN, because the MOVE was blocked before, if the content page does not give a DOWN to reset the slide starting point, there will be a moment of sliding a long distance of the trap effect.

    @Override
    public boolean dispatchTouchEvent(final MotionEvent event) {

        if(! mIsRefreshable) {// Disable pull-down refresh and distribute events directly
            return super.dispatchTouchEvent(event);
        }

        if ((mCurrentState == REFRESHING 
             || mCurrentState == RELEASE_TO_REFRESH 
              || mCurrentState == HIDING) 
               && mHeaderAnimator.isRunning()) {
            // Refresh, release, hide headers are not processed, and are not distributed
            return true;
        }

        // Support multi-finger touch
        int actionMasked = MotionEventCompat.getActionMasked(event);

        switch (actionMasked) {

            case MotionEvent.ACTION_DOWN: {
                // Record the response finger
                mActivePointerId = event.getPointerId(0);
                // Record the initial Y coordinates
                mInitDownY = mLastMotionY = event.getY(0);
            }
            break;

            case MotionEvent.ACTION_POINTER_DOWN: {
                // Press another finger to switch to this finger response
                int pointerDownIndex = MotionEventCompat.getActionIndex(event);
                if (pointerDownIndex < 0) {
                    Log.e("RefreshLayout"."296 lines - the dispatchTouchEvent () :" + "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return dispatchTouchEvent(event);
                }
                mActivePointerId = event.getPointerId(pointerDownIndex);
                mLastMotionY = event.getY(pointerDownIndex);
            }
            break;

            case MotionEvent.ACTION_POINTER_UP: {
                // The other finger lifts and switches back to the other finger to respond
                final int pointerUpIndex = MotionEventCompat.getActionIndex(event);
                final int pointerId = event.getPointerId(pointerUpIndex);
                if (pointerId == mActivePointerId) {
                    // Lift the finger is the previous control slide finger, switch the other finger response
                    final int newPointerIndex = pointerUpIndex == 0 ? 1 : 0;
                    mActivePointerId = event.getPointerId(newPointerIndex);
                }
                mLastMotionY = event.getY(event.findPointerIndex(mActivePointerId));
            }
            break;

            case MotionEvent.ACTION_MOVE: {
                // Move the event
                if (mActivePointerId == INVALID_POINTER) {
                    Log.e("RefreshLayout"."235 lines - the dispatchTouchEvent () :" + "Got ACTION_MOVE event but don't have an active pointer id.");
                    return dispatchTouchEvent(event);
                }

                float y = event.getY(event.findPointerIndex(mActivePointerId));
                // The offset of the move
                float yDiff = y - mLastMotionY;
                mLastMotionY = y;

                if (mContentViewOffset == 0 && (yDiff > 0 || (yDiff < 0 && isHeaderShowing()))) {
                    // Handle the slide event while the content control is still scrolling, pulling down, or sliding up while the header is still displayed

                    // Total sliding distance
                    float totalDistanceY = mLastMotionY - mInitDownY;
                    if (totalDistanceY > 0 && totalDistanceY <= mScaledTouchSlop && yDiff > 0) {
                        // When pulling down, optimize the sliding logic, do not respond with a little displacement
                        return super.dispatchTouchEvent(event);
                    }

                    // The event is being processed
                    mIsHeaderHandling = true;

                    if (mCurrentState == REFRESHING) {
                        // Is refreshing and does not allow the contentView to slide in response
                        event.setAction(MotionEvent.ACTION_CANCEL);
                    }

                    // Handle the pull-down header
                    scrollHeader(yDiff);

                    break;

                } else if (mIsHeaderHandling) {
                    // Special handling of events at the moment when the head is hidden
                    if (mContentViewScrollable) {
                        ACTION_DOWN is required to re-inform the starting point of sliding, otherwise it will slide a certain distance instantly
                        // 2. Set the click event for the non-sliding View. If you give it an ACTION_DOWN event, the ACTION_UP event will trigger the click when the finger is lifted, so this is handled here
                        event.setAction(MotionEvent.ACTION_DOWN);
                    }
                    mIsHeaderHandling = false; }}break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP: {
                // Handle finger lift or cancel events
                mActivePointerId = INVALID_POINTER;
                if (isHeaderShowing()) {
                    // In the case of the header display
                    if (actionMasked == MotionEvent.ACTION_CANCEL) {
                        // The autoScrollHeader will hide the headermCurrentState = PULL_TO_REFRESH; } autoScrollHeader(); }}break;

            default:
                break;
        }

        if(mCurrentState ! = REFRESHING && isHeaderShowing() && actionMasked ! = MotionEvent.ACTION_UP && actionMasked ! = MotionEvent.ACTION_POINTER_UP) {// Not at refresh time, and the header is displayed, in some cases do not allow the contentView to respond to events
            event.setAction(MotionEvent.ACTION_CANCEL);
        }

        return super.dispatchTouchEvent(event);
    }Copy the code

Head processing logic: get the drop-down offset, and then dynamically set the paddingTop value of the head, you can realize show and hide; When the finger is raised, the state determines whether to display refresh or hide the head directly

   /** * pull the head ** @paramDiff pull distance */
    private void scrollHeader(float diff) {
        // Divided by 3 equals the damping value
        diff /= 3;
        // Calculate the position of the head after the move
        int top = (int) (diff + mHeader.getPaddingTop());
        // Control the head position from -mheaderheight to mHeaderHeight * 4
        mHeader.setPadding(0, Math.min(Math.max(top, -mHeaderHeight), mHeaderHeight * 3), 0.0);
        if (mCurrentState == REFRESHING) {
            // The state is still being refreshed
            mHeader.setText("Refreshing...");
            return;
        }
        if (mHeader.getPaddingTop() > mHeaderHeight / 2) {
            > mHeaderHeight / 2
            mHeader.setText("Can release refresh...");
            mCurrentState = RELEASE_TO_REFRESH;
        } else {
            // Drop down state
            mHeader.setText("Pulling down..."); mCurrentState = PULL_TO_REFRESH; }}/** * Perform a header show or hide slide */
    private void autoScrollHeader() {
        // Handle the lift event
        if (mCurrentState == RELEASE_TO_REFRESH) {
            // release the refresh state, lift the finger, animate the head back to (0,0) position
            mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
            mHeaderAnimator.setDuration(DEFAULT_DURATION);
            mHeaderAnimator.start();
            mHeader.setText("Releasing...");
        } else if (mCurrentState == PULL_TO_REFRESH || mCurrentState == REFRESHING) {
            // Drop down state or refresh state, hide the header through animation
            mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
            if (mHeader.getPaddingTop() <= 0) {
                mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 1.0 / 
                 mHeaderHeight * (mHeader.getPaddingTop() + mHeaderHeight)));
            } else {
                mHeaderAnimator.setDuration(DEFAULT_DURATION);
            }
            mHeaderAnimator.start();
            if (mCurrentState == PULL_TO_REFRESH) {
                // In the drop-down state, change the state to hiding the head
                mCurrentState = HIDING;
                mHeader.setText("Pull your head in..."); }}}Copy the code

You might be asking, how do I know this mContentViewOffset? Next is the processing method, I will for different sliding controls, to set their sliding distance listening, methods of various, through the handleTargetOffset to identify the type of View to adopt different strategies; And then you might think what if I want to implement listening on that control? Simply inherit the listener I’ve already implemented and add the functionality you want. You can’t call the handleTargetOffset method anymore.

    // Set the content page slide distance
    public void setContentViewOffset(int offset) {
        mContentViewOffset = offset;
    }

    /** * Different types of views are used to calculate the sliding distance ** @paramView content view */
    public void handleTargetOffset(View view) {
        if (view instanceof RecyclerView) {

            ((RecyclerView) view).addOnScrollListener(new RecyclerViewOnScrollListener());

        } else if (view instanceof NestedScrollView) {

            ((NestedScrollView) view).setOnScrollChangeListener(new NestedScrollViewOnScrollChangeListener());

        } else if (view instanceof WebView) {

            view.setOnTouchListener(new WebViewOnTouchListener());

        } else if (view instanceof ScrollView) {

            view.setOnTouchListener(new ScrollViewOnTouchListener());

        } else if (view instanceof ListView) {

            ((ListView) view).setOnScrollListener(newListViewOnScrollListener()); }}/** * for RecyclerView sliding distance monitor */
    public class RecyclerViewOnScrollListener extends RecyclerView.OnScrollListener {

        int offset = 0;

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy); offset += dy; setContentViewOffset(offset); }}/** * applies to NestedScrollView slide distance listener */
    public class NestedScrollViewOnScrollChangeListener implements NestedScrollView.OnScrollChangeListener {

        @Override
        public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, intoldScrollY) { setContentViewOffset(scrollY); }}/** * applies to WebView slide distance listener */
    public class WebViewOnTouchListener implements View.OnTouchListener {

        @Override
        public boolean onTouch(View view, MotionEvent motionEvent) {
            setContentViewOffset(view.getScrollY());
            return false; }}/** * applies to ScrollView slide distance listener */
    public class ScrollViewOnTouchListener extends WebViewOnTouchListener {

    }

    /** * applies to the ListView slide distance listener */
    public class ListViewOnScrollListener implements AbsListView.OnScrollListener {

        @Override
        public void onScrollStateChanged(AbsListView absListView, int i) {

        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            if (firstVisibleItem == 0) {
                View c = view.getChildAt(0);
                if (c == null) {
                    return;
                }
                int firstVisiblePosition = view.getFirstVisiblePosition();
                int top = c.getTop();
                int scrolledY = -top + firstVisiblePosition * c.getHeight();
                setContentViewOffset(scrolledY);
            } else {
                setContentViewOffset(1); }}}Copy the code

Google SwipeRefreshLayout provides setRefreshing to enable or disable the refresh animation. Call setRefreshing(true) on onCreate with SwipeRefreshLayout.

    public void setRefreshing(boolean refreshing) {
        if(refreshing && mCurrentState ! = REFRESHING) {// Force the refresh header
            openHeader();
        } else if (!refreshing) {
            closeHeader();
        }
    }

    private void openHeader() {
        post(new Runnable() {
            @Override
            public void run() {
                mCurrentState = RELEASE_TO_REFRESH;
                mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 2.5));
                mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0); mHeaderAnimator.start(); }}); }private void closeHeader() {
        mHeader.setText("Refresh complete, retract head...");
        mCurrentState = HIDING;
        mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
        // 0~ -mheaderheight time DEFAULT_DURATION
        mHeaderAnimator.setDuration(DEFAULT_DURATION);
        mHeaderAnimator.start();
    }Copy the code

3.3. Effect display

In addition to the above three in the Demo implementation of ListView, ViewPager, ScrollView, NestedScrollView, see the code

The Demo address:Github:RefreshLayoutDemoGive a Star if you think it’s good.