Android imitates today's headline details page---multi-subview nested scrolling scheme

1. Background

Project address: ELinkageScrollLayout

Similar to the realization of news details pages of products such as Baidu APP and Toutiao, ELinkageScrollLayout provides a solution for nested sliding of multiple sub-views. Hereinafter, we will collectively call it "linkage container"

Linkage container (ELinkageScrollLayout) has the following advantages:

  1. Unlimited number of subviews;
  2. The type of subview is not limited;

Let’s intuitively experience the Demo effect of nested scrolling of linked containers:

2. Analysis

Similar to most custom controls, linkage containers also need to handle the measurement, layout, and gesture handling of subviews. Measurement and layout are very simple for the scene of linked containers. Gesture processing is the key to realize linked containers, and it is also the most difficult part.

As can be seen from the demo effect, the linkage container needs to handle the nested sliding problem with subviews. There are two solutions for nested sliding. 1. It is based on Google’s NestedScrolling mechanism to realize nested sliding; 2. It is the logic of nested sliding of sub-views handled by the linkage container. The linkage container of the early version of Baidu App was realized by Scheme 2. The following figure shows the gesture processing flow of Scheme 2 linkage container:


In addition, we have made the implementation code of scheme 2 linkage container open source, interested students can refer to: https://github.com/baiduapp-tec/LinkageScrollLayout .

Next, we elaborate on Solution 1, which is a linked container based on Google's NestedScrolling mechanism.

3. Detailed design

3.1 Google nested sliding mechanism

Google launched a set of NestedScrolling mechanism in Android 5.0. This set of scrolling mechanism breaks the cognition of the traditional event processing of Android before. It handles nested scrolling according to the reverse event delivery mechanism. For event delivery, please refer to the following figure: According to the above



figure A brief description of the NestedScrolling mechanism: the event is passed from the NestedScrollingParent to the NestedScrollingChild. Before consuming the event, the NestedScrollingChild will first look up the NestedScrollingParent. If found, it will notify them of the specific event. After NestedScrollingParent receives the event, it can choose to consume/not consume/consume some events, NestedScrollingParent returns the processing result to NestedScrollingChild, NestedScrollingChild receives the processing result of the event from NestedScrollingParent, and then decides the subsequent event processing behavior.

There are many articles about NestedScrolling on the Internet. If you have never been in touch with NestedScrolling, you can refer to this article by Zhang Hongyang: https://blog.csdn.net/lmj623565791/article/details/52204039

3.2 Interface design

In order to ensure the arbitrariness of the sub-views in the linkage container, the linkage container needs to provide a complete interface abstraction for the sub-views to implement. The following figure is the interface class diagram exposed by the linkage container:

Combined with the above class diagram, give a brief explanation of each interface:

ILinkageScroll is an interface that must be implemented by the sub-view placed in the linkage container. If the linkage container finds that a sub-view does not implement this interface during initialization, an exception will be thrown. ILinkageScroll will involve two interfaces: LinkageScrollHandler, ChildLinkageEvent.

  • The method linkage container in the LinkageScrollHandler interface will actively call when needed to notify the child view to complete some functions, such as: obtain whether the child view is scrollable, obtain data related to the scroll bar of the child view, etc.
  • The ChildLinkageEvent interface defines some event information of the child view, such as scrolling the content of the child view to the top or bottom. When these events occur, the child view actively calls the corresponding method, so that the linkage container will respond accordingly after receiving some events from the child view to ensure the normal linkage effect.

The above only briefly explains the function of the interface. For those who want to know more about it, please refer to: https://github.com/baiduapp-tec/ELinkageScroll

Next, let’s analyze the gesture processing details of the linkage container in detail. According to the gesture type, the nested sliding is divided into two situations for analysis: 1. scroll gesture; 2. fling gesture;

3.3 scroll gesture

First give the core code of scroll gesture processing:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    
    
	@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
    
    

        boolean moveUp = dy > 0;
        boolean moveDown = !moveUp;
        int scrollY = getScrollY();
        int topEdge = target.getTop();
        LinkageScrollHandler targetScrollHandler
                = ((ILinkageScroll)target).provideScrollHandler();

        if (scrollY == topEdge) {
    
    	// 联动容器scrollY与当前子view的top坐标重合			
            if ((moveDown && !targetScrollHandler.canScrollVertically(-1))
                    || (moveUp && !targetScrollHandler.canScrollVertically(1))) {
    
    
				// 在对应的滑动方向上,如果子view不能垂直滑动,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            } 
        } else if (scrollY > topEdge) {
    
    	// 联动容器scrollY大于当前子view的top坐标,也就是说,子view头部已经滑出联动容器
            if (moveUp) {
    
    
				// 如果手指上滑,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveDown) {
    
    
				// 如果手指下滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断减小,
				// 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end > topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        } else if (scrollY < topEdge) {
    
    	// 联动容器scrollY小于当前子view的top坐标,也就是说,子view还没有完全露出
            if (moveDown) {
    
    
				// 如果手指下滑,则由联动容器消费滚动距离
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveUp) {
    
    
				// 如果手指上滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断增大,
				// 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end < topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        }
    }

    @Override
    public void scrollBy(int x, int y) {
    
    
        // 边界检查
        int scrollY = getScrollY();
        int deltaY;
        if (y < 0) {
    
    
            deltaY = (scrollY + y) < 0 ? (-scrollY) : y;
        } else {
    
    
            deltaY = (scrollY + y) > mScrollRange ?
                    (mScrollRange - scrollY) : y;
        }
        if (deltaY != 0) {
    
    
            super.scrollBy(x, deltaY);
        }
    }
}

The onNestedPreScroll() callback is a method in the NestedScrollingParent interface of Google's nested sliding mechanism. When the child view scrolls, it will first ask the parent view whether to consume this scrolling distance through this method. The parent view decides whether to consume and how much to consume according to its own situation, and puts the consumed distance into the consumed array, and the child view then according to the array The content determines its own scrolling distance.

The code comments are very detailed, here is an overall explanation: By comparing the upper edge threshold of the sub-view with the scrollY of the linkage container, the scrolling situation in the three cases is handled. Line 12, when scrollY == topEdge, as long as the child view does not scroll to the top or bottom, the child view will consume the scrolling distance normally, otherwise the linkage container will consume the scrolling distance, and notify the child view of the consumed distance through the consumed variable, the child view It will determine its own sliding distance according to the content in the consumed variable. Line 19, when scrollY > topEdge, that is to say, when the head of the touched subview has slid out of the linkage container, if the finger slides up, all the sliding distance will be consumed by the linkage container; if the finger slides down, the linkage container will Consume part of the distance first, and when the scrollY of the linked container reaches topEdge, the remaining sliding distance will continue to be consumed by the child view. Line 34, when scrollY < topEdge This is similar to the previous line 19 judgment, so I won’t explain too much here. The scroll gesture processing flow chart is as follows:


3.4 fling gesture

The general idea of ​​how the linkage container handles the fling gesture is as follows: if the scrollY of the linkage container is equal to the top coordinate of the child view, the fling gesture is handled by the child view itself, otherwise the fling gesture is handled by the linkage container. Moreover, in a complete fling cycle, the linkage container and each sub-view will alternately complete the sliding behavior until the speed drops to 0. The linkage container needs to handle the speed connection during alternate sliding to ensure the smooth flow of the entire fling. Next, look at the detailed implementation:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    
    

    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
    
    
        int scrollY = getScrollY();
        int targetTop = target.getTop();
        mFlingOrientation = velocityY > 0 ? FLING_ORIENTATION_UP : FLING_ORIENTATION_DOWN;
        if (scrollY == targetTop) {
    
    	// 当联动容器的scrollY等于子view的top坐标,则由子view自身处理fling手势
            // 跟踪velocity,当target滚动到顶或底,保证parent继续fling
            trackVelocity(velocityY);
            return false;
        } else {
    
    	// 由联动容器消费fling手势
            parentFling(velocityY);
            return true;
        }
    }
}

The onNestedPreFling() callback is a method in the NestedScrollingParent interface of Google's nested sliding mechanism. When the fling behavior occurs in the child view, it will first ask the parent view whether to consume the fling gesture through this method. If it returns true, it means that the parent view will consume the fling gesture, otherwise it will not consume it.

Line 7 records the direction of this fling according to the positive and negative values ​​of velocityY; line 8, when the scrollY value of the linkage container is equal to the top value of the touched subview, the fling gesture is processed by the subview, and the linkage container controls the speed of this fling gesture The purpose of tracking is to obtain the remaining speed when the content of the subview scrolls to the top or bottom, so that the linked container can continue to fling; line 13, the linked container consumes this fling gesture. Let's look at the details of the alternate fling of the linkage container and sub-view:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    
    

	@Override
    public void computeScroll() {
    
    
        if (mScroller.computeScrollOffset()) {
    
    
            int y = mScroller.getCurrY();
            y = y < 0 ? 0 : y;
            y = y > mScrollRange ? mScrollRange : y;
			// 获取联动容器下个滚动边界值,如果达到边界值,速度会传给下个子view,让子view继续快速滑动
			int edge = getNextEdge();
			// 边界检查
			if (mFlingOrientation == FLING_ORIENTATION_UP) {
    
    
                y = y > edge ? edge : y;
            }
			// 边界检查
            if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
    
    
                y = y < edge ? edge : y;
            }
			// 联动容器滚动子view
            scrollTo(x, y);
            int scrollY = getScrollY();
			// 联动容器最新的scrollY是否达到了边界值
            if (scrollY == edge) {
    
    
				// 获取剩余的速度
                int velocity = (int) mScroller.getCurrVelocity();
                if (mFlingOrientation == FLING_ORIENTATION_UP) {
    
    
                    velocity = velocity > 0? velocity : - velocity;
                }
                if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
    
    
                    velocity = velocity < 0? velocity : - velocity;
                }	
				// 获取top为edge的子view
                View target = getTargetByEdge(edge);
				// 子view根据剩余的速度继续fling
                ((ILinkageScroll) target).provideScrollHandler()
                        .flingContent(target, velocity);
                trackVelocity(velocity);
            }
            invalidate();
        }
    }

    /**
     * 根据fling的方向获取下一个滚动边界,
     * 内部会判断下一个子View是否isScrollable,
     * 如果为false,会顺延取下一个target的edge。
     */
    private int getNextEdge() {
    
    
        int scrollY = getScrollY();
        if (mFlingOrientation == FLING_ORIENTATION_UP) {
    
    
            for (View target : mLinkageChildren) {
    
    
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int topEdge = target.getTop();
                if (topEdge > scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(1)) {
    
    
                    return topEdge;
                }
            }
        } else if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
    
    
            for (View target : mLinkageChildren) {
    
    
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int bottomEdge = target.getBottom();
                if (bottomEdge >= scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(-1)) {
    
    
                    return target.getTop();
                }
            }
        }
        return mFlingOrientation == FLING_ORIENTATION_UP ? mScrollRange : 0;
    }

    /** 
	 * child view的滚动事件 
	 */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
    
    
        @Override
        public void onContentScrollToTop(View target) {
    
    
			// 子view内容滚动到顶部回调
            if (mVelocityScroller.computeScrollOffset()) {
    
    
				// 从速度追踪器中获取剩余速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity < 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
				// 联动容器根据剩余速度继续fling
                parentFling(currVelocity);
            }
        }

        @Override
        public void onContentScrollToBottom(View target) {
    
    
			// 子view内容滚动到底部回调
            if (mVelocityScroller.computeScrollOffset()) {
    
    
				// 从速度追踪器中获取剩余速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity > 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
				// 联动容器根据剩余速度继续fling
                parentFling(currVelocity);
            }
        }
    };
}

The speed transfer of fling is divided into: 1. Transfer from the linkage container to the child view; 2. Transfer from the child view to the linkage container.

First look at the speed transfer from the linkage container to the child view. The core code is in the computeScroll() callback method. Line 10, get the next scrolling boundary value of the linkage container. If the next scrolling boundary value is reached, the linkage container needs to pass the remaining speed to the next child view to let it continue scrolling. Line 48, the internal overall logic of the getNextEdge() method: traverse all sub-views, compare the current scrollY of the linkage container with the top/bottom of the sub-views to obtain the next sliding boundary. On line 35, when the linkage container detects that it has slid to the next boundary, it calls ILinkageScroll.flingContent() to let the child view continue to scroll according to the remaining speed.

Look at the speed transfer from the child view to the linkage container. The core code is on line 79. When the content of the child view scrolls to the top or bottom, the onContentScrollToTop() method or onContentScrollToBottom() method will be called back. After the linkage container receives the callback, it will continue to perform subsequent scrolling on lines 89 and 102. The flow chart of fling gesture processing is as follows:


4. Scroll bar

4.1 ScrollBar of Android system

For pages with scrollable content, ScrollBar is an indispensable UI component. ScrollBar is convenient for the user to locate the current reading position, and reminds the user how much content is left below. Therefore, ScrollBar is also a function that the linkage container must implement.

Fortunately, the Android system is very friendly to the abstraction of the scroll bar. The custom control only needs to rewrite a few methods in the View, and the Android system can help you draw the scroll bar correctly. Let's first look at the related methods in View:

/**
 * <p>Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. This value is used to compute the position
 * of the thumb within the scrollbar's track.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and
 * {@link #computeVerticalScrollExtent()}.</p>
 *
 * @return the vertical offset of the scrollbar's thumb
 */
protected int computeVerticalScrollOffset() {
    
    
    return mScrollY;
}

/**
 * <p>Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length
 * of the thumb within the scrollbar's track.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and
 * {@link #computeVerticalScrollOffset()}.</p>
 *
 * @return the vertical extent of the scrollbar's thumb
 */
protected int computeVerticalScrollExtent() {
    
    
    return getHeight();
}

/**
 * <p>Compute the vertical range that the vertical scrollbar represents.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollExtent()} and
 * {@link #computeVerticalScrollOffset()}.</p>
 *
 * @return the total vertical range represented by the vertical scrollbar
 */
protected int computeVerticalScrollRange() {
    
    
    return getHeight();
}

For the vertical Scrollbar, we only need to rewrite the three methods computeVerticalScrollOffset(), computeVerticalScrollExtent(), and computeVerticalScrollRange(). Android has already commented on these three methods in great detail, here is a brief explanation:

  • computeVerticalScrollOffset() indicates the offset value of the scrolling of the current page content, which is used to control the position of the Scrollbar. The default value is the scroll value in the Y direction of the current page.
  • computeVerticalScrollExtent() indicates the range of the scroll bar, that is, the maximum limit that the scroll bar can touch in the vertical direction, and this value will also be used by the system to calculate the length of the scroll bar. The default value is the actual height of the View.
  • computeVerticalScrollRange() indicates the scrollable numerical range of the entire page content, and the default value is the actual height of the View.

It should be noted that the three values ​​of offset, extent and range must be consistent in units.

4.2 Linkage container realizes ScrollBar

The linked container is composed of scrollable sub-views in the system. These sub-views (ListView, RecyclerView, WebView) must have implemented the ScrollBar function, so it is very simple for the linked container to implement ScrollBar. The linked container only needs to get all the sub-views The offset, extent, and range values ​​of the linked container, and then convert these values ​​of all sub-views into the corresponding offset, extent, and range of the linked container according to the sliding logic of the linked container. Let me look at the detailed code below:

public interface LinkageScrollHandler {
    
    
    // ...省略无关代码

    /**
     * get scrollbar extent value
     *
     * @return extent
     */
    int getVerticalScrollExtent();

    /**
     * get scrollbar offset value
     *
     * @return extent
     */
    int getVerticalScrollOffset();

    /**
     * get scrollbar range value
     *
     * @return extent
     */
    int getVerticalScrollRange();
}

The LinkageScrollHandler interface has been explained in Section 3.2, so I won't repeat it here. The three methods are implemented by the sub-view, and the linkage container will obtain the values ​​related to the sub-view and the scroll bar through these three methods. Let's take a look at the detailed logic about ScrollBar in the linkage container:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    
    
    
    /** 构造方法 */
    public ELinkageScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {
    
    
        // ...省略了无关代码
        // 确保联动容器调用onDraw()方法
        setWillNotDraw(false);
        // enable vertical scrollbar
        setVerticalScrollBarEnabled(true);
    }

    /** child view的滚动事件 */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
    
    
        // ...省略了无关代码
        @Override
        public void onContentScroll(View target) {
    
    
            // 收到子view滚动事件,显示滚动条
            awakenScrollBars();
        }
    }

    @Override
    protected int computeVerticalScrollExtent() {
    
    
        // 使用缺省的extent值
        return super.computeVerticalScrollExtent();
    }

    @Override
    protected int computeVerticalScrollRange() {
    
    
        int range = 0;
        // 遍历所有子view,获取子view的Range
        for (View child : mLinkageChildren) {
    
    
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childRange = linkageScroll.provideScrollHandler().getVerticalScrollRange();
            range += childRange;
        }
        return range;
    }

    @Override
    protected int computeVerticalScrollOffset() {
    
    
        int offset = 0;
        // 遍历所有子view,获取子view的offset
        for (View child : mLinkageChildren) {
    
    
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childOffset = linkageScroll.provideScrollHandler().getVerticalScrollOffset();
            offset += childOffset;
        }
        // 加上联动容器自身在Y方向上的滚动偏移
        offset += getScrollY();
        return offset;
    }
}

The above is the core code of the linkage container to implement ScrollBar, and the comments are also very detailed. Here are a few more points to emphasize:

  • In order to improve the efficiency of the system, the ViewGroup does not call the onDraw() method by default, so that the drawing logic of the ScrollBar will not be followed. So in line 7, you need to call setWillNotDraw(false) to open the ViewGroup drawing process;
  • In line 18, after receiving the scroll callback of the child view, call awakeScrollBars() to trigger the drawing of the scroll bar;
  • For the extent, directly use the default extent, which is the height of the linkage container;
  • For the range, sum the ranges of all sub-views, and the final value is the range of the linkage container;
  • For the offset, first sum the offsets of all sub-views, and then add the scrollY value of the linkage container itself, and the final value is the offset of the linkage container.

You can go back to the beginning of the article and look at the effect of the scroll bar in the demo. Compared with other apps on the market that use similar linkage technology, the implementation of the scroll bar in this article is very close to the original.

V. Summary

The linkage container and the sub-views alternately consume fling gestures. The linkage container needs to handle the speed transfer at the boundary to ensure the continuity of the overall sliding;

Guess you like

Origin blog.csdn.net/H_Zhang/article/details/103615998