写点什么

Android 技术分享| 自定义 LayoutManager

作者:anyRTC开发者
  • 2022 年 6 月 14 日
  • 本文字数:7214 字

    阅读完需:约 24 分钟

效果预览



注:文本只是简单的 Demo,功能单一,主要讲解流程与步骤,请根据特定的需求修改。

各属性图:


因为 item 随着滑动会有不同的缩放,所以实际 normalViewGap 会被缩放计算。


我们在自定义 ViewGroup 中,想要显示子 View,无非就三件事:


  1. 添加 通过 addView 方法把子 View 添加进 ViewGroup 或直接在 xml 中直接添加;

  2. 测量 重写 onMeasure 方法并在这里决定自身尺寸以及每一个子 View 大小;

  3. 布局 重写 onLayout 方法,在里面调用子 View 的 layout 方法来确定它的位置和尺寸;


其实在自定义 LayoutManager 中,在流程上也是差不多的,我们需要重写 onLayoutChildren 方法,这个方法会在初始化或者 Adapter 数据集更新时回调,在这方法里面,需要做以下事情:


  1. 进行布局之前,我们需要调用 detachAndScrapAttachedViews 方法把屏幕中的 Items 都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);

  2. 分离了之后,我们就要想办法把它们再添加回去了,所以需要通过 addView 方法来添加,那这些 View 在哪里得到呢? 我们需要调用 Recycler 的 getViewForPosition(int position) 方法来获取;

  3. 获取到 Item 并重新添加了之后,我们还需要对它进行测量,这时候可以调用 measureChild 或 measureChildWithMargins 方法,两者的区别我们已经了解过了,相信同学们都能根据需求选择更合适的方法;

  4. 在测量完还需要做什么呢? 没错,就是布局了,我们也是根据需求来决定使用 layoutDecorated 还是 layoutDecoratedWithMargins 方法;

  5. 在自定义 ViewGroup 中,layout 完就可以运行看效果了,但在 LayoutManager 还有一件非常重要的事情,就是回收了,我们在 layout 之后,还要把一些不再需要的 Items 回收,以保证滑动的流畅度;

布局实现

再看下相关参数:



如果去掉 itemView 的缩放,透明度动画,那么效果是这样的:



看到的效果与LinearLayoutManager一样,但本篇并不使用LinearLayoutManager,而是通过自定义LayoutManager来实现。


索引值为 0 的 view 一次完全滑出屏幕所需要的移动距离,定位为firstChildCompleteScrollLength;非索引值为 0 的 view 滑出屏幕所需要移动的距离为:firstChildCompleteScrollLength + onceCompleteScrollLength; item 之间的间距为 normalViewGap


我们在 scrollHorizontallyBy 方法中记录偏移量 dx,保存一个累计偏移量 mHorizontalOffset ,然后针对索引值为 0 与非 0 两种情况,在 mHorizontalOffset 小于 firstChildCompleteScrollLength 情况下,用该偏移量除以 firstChildCompleteScrollLength 获取到已经滚动了的百分比 fraction ;同理索引值非 0 的情况下,偏移量需要减去 firstChildCompleteScrollLength 来获取到滚动的百分比。根据百分比,怎么布局 childview 就很容易了。


接下来开始写代码,我们创建类:StackLayoutManager


StackLayoutManager 继承 RecyclerView.LayoutManager ,需要重写 generateDefaultLayoutParams 方法:


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


成员变量:


    /**     * 一次完整的聚焦滑动所需要的移动距离     */    private float onceCompleteScrollLength = -1;
/** * 第一个子view的偏移量 */ private float firstChildCompleteScrollLength = -1;
/** * 屏幕可见第一个view的position */ private int mFirstVisiPos;
/** * 屏幕可见的最后一个view的position */ private int mLastVisiPos;
/** * 水平方向累计偏移量 */ private long mHorizontalOffset;
/** * view之间的margin */ private float normalViewGap = 30;
private int childWidth = 0;
/** * 是否自动选中 */ private boolean isAutoSelect = true; // 选中动画 private ValueAnimator selectAnimator;
复制代码


scrollHorizontallyBy 方法:


    @Override    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {        // 手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;        // 位移0、没有子View 当然不移动        if (dx == 0 || getChildCount() == 0) {            return 0;        }
// 误差处理 float realDx = dx / 1.0f; if (Math.abs(realDx) < 0.00000001f) { return 0; }
mHorizontalOffset += dx;
dx = fill(recycler, state, dx);
return dx; }
private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) { int resultDelta = dx; resultDelta = fillHorizontalLeft(recycler, state, dx); recycleChildren(recycler); return resultDelta; }
private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) { if (dx < 0) { // 已到达左边界 if (mHorizontalOffset < 0) { mHorizontalOffset = dx = 0; } }
if (dx > 0) { if (mHorizontalOffset >= getMaxOffset()) { // 根据最大偏移量来计算滑动到最右侧边缘 mHorizontalOffset = (long) getMaxOffset(); dx = 0; } }
// 分离全部的view,加入到临时缓存 detachAndScrapAttachedViews(recycler);
float startX = 0; float fraction = 0f; boolean isChildLayoutLeft = true;
View tempView = null; int tempPosition = -1;
if (onceCompleteScrollLength == -1) { // 因为mFirstVisiPos在下面可能被改变,所以用tempPosition暂存一下 tempPosition = mFirstVisiPos; tempView = recycler.getViewForPosition(tempPosition); measureChildWithMargins(tempView, 0, 0); childWidth = getDecoratedMeasurementHorizontal(tempView); }
// 修正第一个可见view mFirstVisiPos 已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item firstChildCompleteScrollLength = getWidth() / 2 + childWidth / 2; if (mHorizontalOffset >= firstChildCompleteScrollLength) { startX = normalViewGap; onceCompleteScrollLength = childWidth + normalViewGap; mFirstVisiPos = (int) Math.floor(Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) / onceCompleteScrollLength) + 1; fraction = (Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f); } else { mFirstVisiPos = 0; startX = getMinOffset(); onceCompleteScrollLength = firstChildCompleteScrollLength; fraction = (Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f); }
// 临时将mLastVisiPos赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局 mLastVisiPos = getItemCount() - 1;
float normalViewOffset = onceCompleteScrollLength * fraction; boolean isNormalViewOffsetSetted = false;
for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) { View item; if (i == tempPosition && tempView != null) { // 如果初始化数据时已经取了一个临时view item = tempView; } else { item = recycler.getViewForPosition(i); }
addView(item); measureChildWithMargins(item, 0, 0);
if (!isNormalViewOffsetSetted) { startX -= normalViewOffset; isNormalViewOffsetSetted = true; }
int l, t, r, b; l = (int) startX; t = getPaddingTop(); r = l + getDecoratedMeasurementHorizontal(item); b = t + getDecoratedMeasurementVertical(item);
layoutDecoratedWithMargins(item, l, t, r, b);
startX += (childWidth + normalViewGap);
if (startX > getWidth() - getPaddingRight()) { mLastVisiPos = i; break; } } return dx; }
复制代码


其他用到的方法:


    /**     * 最大偏移量     *     * @return     */    private float getMaxOffset() {        if (childWidth == 0 || getItemCount() == 0) return 0;        return (childWidth + normalViewGap) * (getItemCount() - 1);    }
/** * 获取某个childView在水平方向所占的空间,将margin考虑进去 * * @param view * @return */ public int getDecoratedMeasurementHorizontal(View view) { final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); return getDecoratedMeasuredWidth(view) + params.leftMargin + params.rightMargin; }
/** * 获取某个childView在竖直方向所占的空间,将margin考虑进去 * * @param view * @return */ public int getDecoratedMeasurementVertical(View view) { final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); return getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin; }
复制代码


item 的回收复用:


    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {        int resultDelta = delta;        // ...
recycleChildren(recycler); log("childCount= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size()); return resultDelta; }
/** * 回收需回收的Item。 */ private void recycleChildren(RecyclerView.Recycler recycler) { List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList(); for (int i = 0; i < scrapList.size(); i++) { RecyclerView.ViewHolder holder = scrapList.get(i); removeAndRecycleView(holder.itemView, recycler); } }
复制代码


动画效果:


    private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {        // ...        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {            // ...
// 缩放子view final float minScale = 0.6f; float currentScale = 0f; final int childCenterX = (r + l) / 2; final int parentCenterX = getWidth() / 2; isChildLayoutLeft = childCenterX <= parentCenterX; if (isChildLayoutLeft) { final float fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f); currentScale = 1.0f - (1.0f - minScale) * fractionScale; } else { final float fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f); currentScale = 1.0f - (1.0f - minScale) * fractionScale; } item.setScaleX(currentScale); item.setScaleY(currentScale); item.setAlpha(currentScale);
layoutDecoratedWithMargins(item, l, t, r, b); // ... } return dx; }
复制代码


childView 越向屏幕中间移动缩放比越大,越向两边移动缩放比越小。


  1. 滚动停止后自动选中:


监听 onScrollStateChanged,在滚动停止时计算出应当停留的 position,再计算出停留时的 mHorizontalOffset 值,播放属性动画将当前 mHorizontalOffset 不断更新至最终值即可。


相关代码如下:


    @Override    public void onScrollStateChanged(int state) {        super.onScrollStateChanged(state);        switch (state) {            case RecyclerView.SCROLL_STATE_DRAGGING:                //当手指按下时,停止当前正在播放的动画                cancelAnimator();                break;            case RecyclerView.SCROLL_STATE_IDLE:                //当列表滚动停止后,判断一下自动选中是否打开                if (isAutoSelect) {                    //找到离目标落点最近的item索引                    smoothScrollToPosition(findShouldSelectPosition());                }                break;            default:                break;        }    }
/** * 平滑滚动到某个位置 * * @param position 目标Item索引 */ public void smoothScrollToPosition(int position) { if (position > -1 && position < getItemCount()) { startValueAnimator(position); } }
private int findShouldSelectPosition() { if (onceCompleteScrollLength == -1 || mFirstVisiPos == -1) { return -1; } int position = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap)); int remainder = (int) (Math.abs(mHorizontalOffset) % (childWidth + normalViewGap)); // 超过一半,应当选中下一项 if (remainder >= (childWidth + normalViewGap) / 2.0f) { if (position + 1 <= getItemCount() - 1) { return position + 1; } } return position; }
private void startValueAnimator(int position) { cancelAnimator();
final float distance = getScrollToPositionOffset(position);
long minDuration = 100; long maxDuration = 300; long duration;
float distanceFraction = (Math.abs(distance) / (childWidth + normalViewGap));
if (distance <= (childWidth + normalViewGap)) { duration = (long) (minDuration + (maxDuration - minDuration) * distanceFraction); } else { duration = (long) (maxDuration * distanceFraction); } selectAnimator = ValueAnimator.ofFloat(0.0f, distance); selectAnimator.setDuration(duration); selectAnimator.setInterpolator(new LinearInterpolator()); final float startedOffset = mHorizontalOffset; selectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); mHorizontalOffset = (long) (startedOffset + value); requestLayout(); } }); selectAnimator.start(); }
复制代码


  1. 点击非焦点 view 自动将其选中为焦点 view


我们可以直接拿到 view 的 position,直接调用 smoothScrollToPosition 方法,就可以实现自动选中为焦点。


中间 view 覆盖在两边 view 之上,效果:



从效果中可以看出,索引为 2 的 view 覆盖在 1,3 的上面,同时 1 又覆盖在 0 的上面,以此内推。


RecyclerView 继承于 ViewGroup ,那么在添加子 view addView(View child, int index) 中 index 的索引值越大,越显示在上层。那么可以得出,为 2 的绿色卡片被添加是 index 最大,分析可以得出以下结论:


index 的大小:


0 < 1 < 2 > 3 > 4


中间最大,两边逐渐减小的原则。


获取到中间 view 的索引值,如果小于等于该索引值则调用 addView(item) ,反之调用 addView(item, 0) ;相关代码如下:


    private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {        // ...        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {             // ...            int focusPosition = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap));            if (i <= focusPosition) {                addView(item);            } else {                addView(item, 0);            }             // ...        }        return dx;    }
复制代码



发布于: 刚刚阅读数: 5
用户头像

实时交互,万物互联! 2020.08.10 加入

实时交互,万物互联,全球实时互动云服务商领跑者!

评论

发布
暂无评论
Android技术分享| 自定义LayoutManager_音视频_anyRTC开发者_InfoQ写作社区