Custom Layout Manager and SnapHelper for RecyclerView

Posted by siwelis on Thu, 16 May 2019 10:03:58 +0200

Mainly to achieve the effect similar to tremolo page turning, but there are some differences, we need to leak out the back view at the bottom, which may not be easy to understand. Look at Demo, slide by page. The back view has zoom-in and zoom animation. If the sliding speed is too small, it will return to the original effect. The sliding is also the effect of sliding by page.

Picture

Some small partners may say that this can be done with SnapHelper, yes, page turning is to combine this, but it is not purely dependent on this, because the bottom needs to leak out the view behind, so Layout Manager can not simply use Linear Layout Manager, need to customize Layout Manager, and then customize SnapHelper.

Look at the custom Layout Manager first.

1. Customize Layout Manager

Android provides us with several Layout Managers, such as Linear Layout Manager: for horizontal or vertical sliding.
GridLayout Manager: For table layout, a row can have multiple columns
Staggered Grid Layout Manager: Waterfall Flow Layout

But we don't need the interface above, because the Item at the bottom of the first page needs to be leaked out, so we need to customize it.

There are three ways to customize LayoutManager in general:

The first method is generateDefaultLayoutParams, which is used to define layout parameters. Generally, WRAP_CONTENT is the width and height.

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,
                RecyclerView.LayoutParams.WRAP_CONTENT);
    }

The second way to distinguish between horizontal and vertical slides is to rewrite canScrollVertically.

    @Override
    public boolean canScrollVertically() {
        return true;
    }

Smart, you must have known that if you slide horizontally, you're rewriting canScroll Horizontally.

The first two methods are very simple, and the most troublesome one is the third method. Rewriting Layout Manager requires you to layout yourself, so you need to rewrite it.

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (state.getItemCount() == 0 || state.isPreLayout()) {
            return;
        }

        if (!hasChild) {
            hasChild = true;
        }

        mItemCount = getItemCount();
        // Sliding distance
        mScrollOffset = Math.min(Math.max(0, mScrollOffset), (mItemCount - 1) * itemHeight);

        layoutChild(recycler);
    }

Firstly, if there is no item or the first layout, it will return directly. mScrollOffset is the sliding distance, the initial value is 0, and sliding to the last can not be sliding. In fact, a drop-down refresh can be added here, and there is a chance to add it later, not today's theme.

The next big head is layoutChild.

    private void layoutChild(RecyclerView.Recycler recycler) {
        if (getItemCount() == 0) {
            return;
        }
        int firstItemPosition = (int) Math.floor(mScrollOffset / itemHeight);
        if (firstItemPosition > commonAdapter.getItemCount() - 1) {
            return;
        }

        int firstItemScrolledHeight = mScrollOffset % itemHeight;
        final float firstItemScrolledHeightPercent = firstItemScrolledHeight * 1.0f / itemHeight;
        ArrayList<PageItemViewInfo> layoutInfos = new ArrayList<>();

        // Compute view location
        int tmpCount = Math.min(VISIBLE_EMOTICON_COUNT, commonAdapter.getItemCount() - firstItemPosition - 1);
        for (int i = 0; i <= tmpCount; i++) {
            // For calculating offset
            int tmp = i + 1;
            double maxOffset = (getVerticalSpace()
                    - itemHeight - firstItemScrolledHeightPercent) / 2 * Math.pow(0.65, tmp);
            if (maxOffset <= 0) {
                break;
            }
            int start;
            if (i == 0) {
                start = getPaddingTop() - firstItemScrolledHeight;
            } else {
                start = (int) (getPaddingTop() + i * maxOffset + i * ITEM_OFFSET);
            }
            float mScale = 0.95f;
            float scaleXY = (float) (Math.pow(mScale, i) * (1 - firstItemScrolledHeightPercent * (1 - mScale)));
            PageItemViewInfo info = new PageItemViewInfo(start, scaleXY);
            layoutInfos.add(0, info);
        }

        // Recycling View
        int layoutCount = layoutInfos.size();
        final int endPos = firstItemPosition + VISIBLE_EMOTICON_COUNT;
        final int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            View childView = getChildAt(i);
            if (childView == null) {
                continue;
            }
            int pos;
            try {
                pos = getPosition(childView);
            } catch (NullPointerException e) {
                e.printStackTrace();
                continue;
            }

            if (pos > endPos + 1 || pos < firstItemPosition - 1) {
                removeAndRecycleView(childView, recycler);
            }
        }

        detachAndScrapAttachedViews(recycler);

        // Add Item
        for (int i = layoutCount - 1; i >= 0; i--) {
            int pos = firstItemPosition + i;
            if (pos > commonAdapter.getItemCount() - 1) {
                break;
            }
            // If a ViewHolder must be constructed and not enough time remains, null is returned, not layout
            View view;
            try {
                view = recycler.getViewForPosition(pos);
            } catch (IndexOutOfBoundsException e) {
                e.printStackTrace();
                return;
            }

            PageItemViewInfo layoutInfo = layoutInfos.get(layoutCount - 1 - i);
            view.setTag(pos);
            addView(view);
            measureChildWithExactlySize(view);
            int left = (getHorizontalSpace() - itemWidth) / 2;
            layoutDecoratedWithMargins(view, left,
                    layoutInfo.getTop(),
                    left + itemWidth,
                    layoutInfo.getTop() + itemHeight);
            view.setPivotX(view.getWidth() / 2);
            view.setPivotY(view.getHeight() / 2);
            view.setScaleX(layoutInfo.getScaleXY());
            view.setScaleY(layoutInfo.getScaleXY());
        }
    }

It is divided into three parts.

Calculating Item Location
Recycling Item
Add Item
Look at them separately below.

Calculating Item Location

It calculates the positions of several Items (I set them to 3) that are visible at the current sliding distance. Because the width and height are fixed, the top position is actually needed. According to the comments, the code should be relatively simple.

        // The first visible Item location
        int firstItemPosition = (int) Math.floor(mScrollOffset / itemHeight);
        // If the first visible Item location is the last Item, return
        if (firstItemPosition > commonAdapter.getItemCount() - 1) {
            return;
        }

        // The first visible distance Item crosses is invisible.
        int firstItemScrolledHeight = mScrollOffset % itemHeight;

        // The first visible percentage of Item's distance to its height
        final float firstItemScrolledHeightPercent = firstItemScrolledHeight * 1.0f / itemHeight;
        ArrayList<PageItemViewInfo> layoutInfos = new ArrayList<>();

        // Compute view location
        int tmpCount = Math.min(VISIBLE_EMOTICON_COUNT, commonAdapter.getItemCount() - firstItemPosition - 1);
        for (int i = 0; i <= tmpCount; i++) {
            // For calculating offset
            int tmp = i + 1;
            double maxOffset = (getVerticalSpace()
                    - itemHeight - firstItemScrolledHeightPercent) / 2 * Math.pow(0.65, tmp);
            if (maxOffset <= 0) {
                break;
            }
            int start;
            if (i == 0) {
                start = getPaddingTop() - firstItemScrolledHeight;
            } else {
                start = (int) (getPaddingTop() + i * maxOffset + i * ITEM_OFFSET);
            }
            float mScale = 0.95f;
            float scaleXY = (float) (Math.pow(mScale, i) * (1 - firstItemScrolledHeightPercent * (1 - mScale)));
            PageItemViewInfo info = new PageItemViewInfo(start, scaleXY);
            layoutInfos.add(0, info);
        }

Recycling Item

RecyclerView provides a three-tier cache, just look at Recycler

 public final class Recycler {
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();

        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();

        RecycledViewPool mRecyclerPool;
     ...
 }

among

mAttachedScrap caches ViewHolder visible on the current screen.

mCachedViews caches reuse item s that are about to enter the screen.

Recycled View Pool can cache ViewHolders that multiple Recycler Views need to share. A SparseArray is maintained internally. Key is View Type of ViewHolder. That is to say, each set of ViewHolders has its own cached data and value is ScrapData type.

public static class RecycledViewPool {
        private static final int DEFAULT_MAX_SCRAP = 5;
        static class ScrapData {
            ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
            int mMaxScrap = DEFAULT_MAX_SCRAP;
            long mCreateRunningAverageNs = 0;
            long mBindRunningAverageNs = 0;
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();
}

The default size of the ArrayList is limited to 5, but this value can be replaced by Recycled ViewPool # setMaxRecycled Views (viewType, max).

Multiple Recycled Views multiplexing can actively fill in data inward through public void put Recycled View (ViewHolder scrap).

References for caching Visual RecyclerView Caching Mechanism
Looking at the caching mechanism of RecyclerView briefly above, we need to recycle Item s that are not visible on the screen, put them in mCachedViews, and then put them in mAttachedScrap in the visible range of the screen, and add them again later. Finally, look at the recycled code:

        int layoutCount = layoutInfos.size();
        final int endPos = firstItemPosition + VISIBLE_EMOTICON_COUNT;
        final int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            View childView = getChildAt(i);
            if (childView == null) {
                continue;
            }
            int pos;
            try {
                pos = getPosition(childView);
            } catch (NullPointerException e) {
                e.printStackTrace();
                continue;
            }

            if (pos > endPos + 1 || pos < firstItemPosition - 1) {
                removeAndRecycleView(childView, recycler);
            }
        }

        detachAndScrapAttachedViews(recycler);

Finally, just add View again according to the calculated location. View is taken from view = recycler.getViewForPosition(pos); and RecyclerView is taken from the cache.

        // Add Item
        for (int i = layoutCount - 1; i >= 0; i--) {
            int pos = firstItemPosition + i;
            if (pos > commonAdapter.getItemCount() - 1) {
                break;
            }
            // If a ViewHolder must be constructed and not enough time remains, null is returned, not layout
            View view;
            try {
                view = recycler.getViewForPosition(pos);
            } catch (IndexOutOfBoundsException e) {
                e.printStackTrace();
                return;
            }

            PageItemViewInfo layoutInfo = layoutInfos.get(layoutCount - 1 - i);
            view.setTag(pos);
            addView(view);
            measureChildWithExactlySize(view);
            int left = (getHorizontalSpace() - itemWidth) / 2;
            layoutDecoratedWithMargins(view, left,
                    layoutInfo.getTop(),
                    left + itemWidth,
                    layoutInfo.getTop() + itemHeight);
            view.setPivotX(view.getWidth() / 2);
            view.setPivotY(view.getHeight() / 2);
            view.setScaleX(layoutInfo.getScaleXY());
            view.setScaleY(layoutInfo.getScaleXY());
        }

The above is the process of customizing Layout Manager. Now the implementation is that Item will move with the finger, without the effect of sliding by page. To achieve the effect of sliding by page, SnapHelper is needed.

2. Customize SnapHelper

SnapHelper has three abstract methods to implement:

    @SuppressWarnings("WeakerAccess")
    @Nullable
    public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager,
            @NonNull View targetView);

    @SuppressWarnings("WeakerAccess")
    @Nullable
    public abstract View findSnapView(LayoutManager layoutManager);

    public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX,
            int velocityY);

The role of three methods:

FindTarget SnapPosition is used to find the final target location. When the fling operation is triggered, it calculates the final target location according to the speed, and then starts the fling operation.
CalulateDistanceToFinalSnap, which is used to calculate the distance to be slipped to the final position, is called when attaching ToRecyclerView or targetView layout at the beginning.
findSnapView is used to find the targetView above, which is the view needed to call before calculateDistanceToFinalSnap calls.

SnapHelper and RecyclerView need to be associated in LayoutManager:

    @Override
    public void onAttachedToWindow(RecyclerView view) {
        super.onAttachedToWindow(view);
        this.snapHelper.attachToRecyclerView(view);
    }

The snapToTargetExistingView method is called in attachToRecyclerView:

    void snapToTargetExistingView() {
        if (mRecyclerView == null) {
            return;
        }
        RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        if (layoutManager == null) {
            return;
        }
        View snapView = findSnapView(layoutManager);
        if (snapView == null) {
            return;
        }
        int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
        if (snapDistance[0] != 0 || snapDistance[1] != 0) {
            mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
        }
    }

It should not be aligned at first. findSnapView will be called to find the View that needs alignment. Look at log:

I: ===========attachToRecyclerView==========
    ===========snapToTargetExistingView==========
    ===========findSnapView==========
    pos = -1

Return to - 1 means there is no alignment required.

Look at the implementation of findSnapView:

    @Nullable
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof SlidePageLayoutManager) {
            int pos = ((SlidePageLayoutManager) layoutManager).getFixedScrollPosition(mDirection);
            mDirection = 0;
            if (pos != RecyclerView.NO_POSITION) {
                View view = layoutManager.findViewByPosition(pos);
                return view;
            }
        }
        return null;
    }

In fact, it calls layoutmanager to get the location pos. The code implementation is simple:

    public int getFixedScrollPosition(int direction) {
        if (hasChild) {
            if (mScrollOffset % itemHeight == 0) {
                return RecyclerView.NO_POSITION;
            }
            float position = mScrollOffset * 1.0f / itemHeight;

            if (direction > 0) {
                position =  (int) Math.ceil(position);
            } else {
                position =  (int) Math.floor(position);
            }
            return (int) position;
        }
        return RecyclerView.NO_POSITION;
    }

ScrollOffset is 0 for the first time, so go back to - 1. Later, calculate the position according to ScrollOffset. If you slide down, take the whole downward. For example, if the first one slides to half, then position is less than 1 decimal. If you take the whole downward, you get 1. So SnapView is the ViewHolder whose position is 1.

Then the finger slides down and scrollState is SCROLL_STATE_DRAGGING. Look at log:

I: ===========onScrollStateChanged==========
    newState = 1
I: ===========******onFling******==========
I: ===========findTargetSnapPosition==========
    ===========getFixedScrollPosition==========
I: ScrollOffset = 461, itemHeight = 1116, position = 1.0, direction = 578
I: ===========onTargetFound==========
    targetView = 1
    ===========calculateDistanceToFinalSnap==========
    ===========calculateDistanceToPosition==========
I: targetPos = 1, distance = 655, scrollOffset = 461
    y = 655
    ===========onScrollStateChanged==========
    newState = 2
I: ===========onScrollStateChanged==========
    newState = 0
I: ===========snapToTargetExistingView==========
    ===========findSnapView==========
    ===========getFixedScrollPosition==========
I: pos = -1

Then it triggers the onFling state. First, it calls back another findTarget SnapPosition to find out where the fling operation needs to scroll. Here we are sliding by page, so we need to scroll to the next View position. See how this method works.

    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
        if (layoutManager.canScrollVertically()) {
            mDirection = velocityY;
            return ((SlidePageLayoutManager) layoutManager).getFixedScrollPosition(mDirection);
        } else {
            mDirection = velocityX;
        }

        return RecyclerView.NO_POSITION;
    }

The call is actually getFixedScrollPosition to compute the location. You can also see from the log that the downward direction is positive, and the next position is 1, because we dragged zero.

The onTargetFound method is called back when we need to lay out the location we need to find (in this case, 1 position). The entry of targetView is the View corresponding to the location we found above. When I lay out, I add a tag corresponding to each View. The targetView.getTag = 1 can also be seen from the log.

Here we need to calculate a RecyclerView also need to scroll the distance to the system, which requires the implementation of the last abstract function calculateDistanceToFinalSnap, calculateTime ForDeceleration method to convert the distance needed to scroll into time, and then through Action.update to notify RecyclerView to slow down and scroll to the final position.

Look at the implementation of calculateDistanceToFinalSnap:

    @Nullable
    @Override
    public int[] calculateDistanceToFinalSnap(
            @NonNull RecyclerView.LayoutManager layoutManager,
            @NonNull View targetView) {
        if (layoutManager instanceof SlidePageLayoutManager) {
            int[] out = new int[2];
            if (layoutManager.canScrollHorizontally()) {
                out[0] = ((SlidePageLayoutManager) layoutManager).calculateDistanceToPosition(
                        layoutManager.getPosition(targetView));
                out[1] = 0;
            } else {
                out[0] = 0;
                out[1] = ((SlidePageLayoutManager) layoutManager).calculateDistanceToPosition(
                        layoutManager.getPosition(targetView));
            }
            LogUtils.LogSlide(null, new String[]{"y"}, out[1]);
            return out;
        }
        return null;
    }

    public int calculateDistanceToPosition(int targetPos) {
        int distance = itemHeight * targetPos - mScrollOffset;
        return distance;
    }

Code fried chicken is simple, that is, by subtracting the current sliding distance from the next position of the layout position, we can get the distance that the Recycler View still needs to slide. The calculation problem of primary school is easy to understand.

Looking at the log s above, you can see that the distance ScrollOffset has slipped is 461, and the height of each item is 116. So 1116 - 461 = 655 is the distance RecyclerView needs to slip.

Unknowingly, we have implemented all three abstract methods that need to be implemented. In the onFling operation, scrollstate is set to 2, which is SCROLL_STATE_SETTLING. Scroll over the distance calculated above and stop. The state changes to 0, which is SCROLL_STATE_IDLE.

Look at the sliding log:

I: ===========onScrollStateChanged==========
I: newState = 1
I: ===========******onFling******==========
I: ===========findTargetSnapPosition==========
I: ===========getFixedScrollPosition==========
    ScrollOffset = 579, itemHeight = 1116, position = 0.0, direction = -446
I: ===========onTargetFound==========
    targetView = 0
    ===========calculateDistanceToFinalSnap==========
I: ===========calculateDistanceToPosition==========
    targetPos = 0, distance = -579, scrollOffset = 579
    y = -579
    ===========onScrollStateChanged==========
    newState = 2
I: ===========onScrollStateChanged==========
I: newState = 0
    ===========snapToTargetExistingView==========
    ===========findSnapView==========
I: ===========getFixedScrollPosition==========
    pos = -1

The scrollstate is almost the same. First it is 1 = SCROLL_STATE_DRAGGING, then it enters the Fling state, then scrollstate becomes 2 = SCROLL_STATE_SETTLING, and finally the stop state becomes 0 = SCROLL_STATE_IDLE.
At the beginning of Fling state, it will be calculated by using findTargetSnapPosition and calling getFixedScrollPosition method. The direction = 446 < 0, so take the whole downward to get 0 and the target position is 0.
The onTarget Found is then called back, and the calculateDistanceToFinalSnap method is used to get the distance needed to slide to the zero target position.

This is the case with custom SnapHelper, which makes it easy to sort out the functions of the three interfaces.

3. summary

Because of the need to achieve PM, we have this custom layout Manager and SnapHelper trip. Custom layout Manager is also implemented in three ways, the most important is to implement onLayout Children, and then lay out each item according to specific needs. Custom SnapHelper also implements three main methods, which are to tell the final sliding position, the View that needs to be aligned, and then the RecyclerView that needs to be sliding distance after the final position corresponding to the View is laid out.

It's not easy to stick to blogging. I learned a lot from blogging online, so I keep telling myself to stick to it.

  • image
image

+ qq group 457848807:. Get the above high-definition technology thinking map and related technology free video learning materials

Topics: Android less