玩转RecyclerView | 实现子视图叠加 | 3D画廊效果 | 高级动效 | Android 3D坐标系介绍

篇章目标要点

之前写的一篇文章展示了RecyclerView实现的画廊效果,适用于专辑/图片/列表浏览效果。本篇文章阐述如何基于RecyclerView实现如下图所示的3D画廊效果。以下效果的重点在于实现子视图的图层叠加,滑动过程中的3D旋转效果较为简单。
(1)无3D旋转效果图片
在这里插入图片描述
(1)带3D旋转效果图片
在这里插入图片描述

实现效果

如下图所示,代码效果可以确保当前显示的子视图居于中间显示,当前显示视图两侧的子视图均会被居中的视图遮挡一部分。
(1) 不增加3D旋转的效果
在这里插入图片描述

(2) 增加3D旋转的效果
在这里插入图片描述

子视图叠加原理

默认情况下子视图是按照顺序绘制和放置的,无法做到图示的效果。RecyclerView支持重置子视图的绘制顺序,设置绘制的思路是当前显示的视图设置为最后绘制,这样即可实现当前显示的视图可以叠加在临近视图的上方,详细设计如下:
在这里插入图片描述
绘制的绘制设置如下

绘制批次子视图序号绘制顺序
1当前视图以左0 ~ i-1
2当前视图以右i ~ N-2
3当前视图N-1
备注:i:当前显示视图在RecyclerView中的序号
N: RecyclerView子视图长度

叠加实现过程

了解了原理之后,按照这个思路实现其代码开发,主要代码是包含对RecyclerView和LayoutManager进行重写。

1. 重写RecylcerView进行子视图绘制顺序重排

RecyclerView工作时是按照其默认顺序规则排列子视图的,如要进行顺序重新排列,则首先需要开启顺序重拍

public GalleryRecyclerView(@NonNull Context context) {
    super(context);
    init();
}

public GalleryRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init();
}

public GalleryRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
}

……
//开启顺序重新排列
private void init(){
    setChildrenDrawingOrderEnabled(true);
}

2. 设置子视图的绘制顺序

其基本思路在子视图叠加原理片段中已经介绍,当前显示视图最后一个绘制,显示视图以左第一批次绘制顺序绘制,显示视图以右第二批次绘制逆序绘制,自定义RecyclerView中相应的实现代码如下

/**
 * 重写视图布置顺序:前半顺序绘制,后半倒序绘制,中间位置
 * 中间位置最后一个绘制count-1
 * 中间位置之前的视图绘制顺序为i
 * 中间位置之后的视图绘制顺序为center+count-1-i
 */
@Override
protected int getChildDrawingOrder(int childCount, int i) {
    GalleryLayoutManager layoutManager = (GalleryLayoutManager) getLayoutManager();
    //计算出中间位置,即当前显示视图的位置
    int center = layoutManager.getCenterVisiblePosition() - layoutManager.getFirstVisiblePosition();
    //序号为i的视图的绘制序号
    int order;
    if(i == center){
        order = childCount - 1;
    }else if(i < center){
        order = i;
    }else{
        order = center + childCount - 1 - i;
    }
    Log.d(TAG,"childCount = "+childCount+",center = "+center+",order = "+order+",i = "+i);
    return order;
}

上述代码实现中依赖自定义LayoutManager计算当前已显示视图的第一个子视图位置,中间的子视图位置,相应的代码如下

/**
 * 计算显示的视图的中间视图的位置,基本思路是基于RecyclerView滑动的距离除以子视图间距
 */
public int getCenterVisiblePosition(){
    int position = mScrollDistanceX / mChildIntervalWidth;
    int offset = mScrollDistanceX % mChildIntervalWidth;
    if(offset > mChildIntervalWidth/2){
        position++;
    }
    return position;
}

//计算显示的第一个视图的位置
public int getFirstVisiblePosition(){
    if(getChildCount() < 0){
        return  0;
    }
    View item = getChildAt(0);
    return getPosition(item);
}

3. 布置子视图

由于布置子视图需要子视图的layout位置,因为在自定义LayoutManager内部使用HashMap分别缓存全部子视图的layout位置,已经是否已经添加显示的信息,定义如下:

/**
 * 用于存储子视图的在RecyclerView中的位置<P/>
 * key为子视图的序号,Rect为子视图的位置<P/>
 */
private Map<Integer , Rect> mChildPositionRects = new HashMap<>();
/**
 * 用于记录子视图是否已经添加至RecyclerView中
 * key为子视图的序号,value为为该子视图是否在可视区域,true表示已显示,false未显示
 */
private Map<Integer , Boolean> mChildHasAttached = new HashMap<>();

布置子视图这部分的主要工作上在可见区域放置子视图,并且对于处于非可见区域的子视图进行回收管理。在自定义LayoutManager中的代码如下

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if(getItemCount() == 0){
        detachAndScrapAttachedViews(recycler);
        return;
    }
    mChildHasAttached.clear();
    mChildPositionRects.clear();
    detachAndScrapAttachedViews(recycler);
    //计算子视图宽度,相邻子视图间距
    if(mChildIntervalWidth <= 0){
        View firstItem = recycler.getViewForPosition(0);
        measureChildWithMargins(firstItem, 0, 0);
        mChildWidth = getDecoratedMeasuredWidth(firstItem);
        mChildHeight = getDecoratedMeasuredHeight(firstItem);
        mChildIntervalWidth = (int) (mChildWidth*OVERLYING_RATIO);
    }
    //子视图水平方向的偏移量
    int offsetX = 0;
    mStartX = getWidth()/2 - mChildWidth/2;
    for(int i = 0 ; i < getItemCount() ; i++){
        Rect rect = new Rect(offsetX + mStartX , 0 ,
                offsetX + mChildWidth + mStartX , mChildHeight);
        mChildPositionRects.put(i , rect);
        mChildHasAttached.put(i,false);
        offsetX += mChildIntervalWidth;
    }
    //添加可视区域的视图
    int visibleCount = getHorizontalSpace() / mChildIntervalWidth;
    Rect visibleRect = getVisibleArea();
    for(int i = 0; i < visibleCount; i++){
        insertView(i, visibleRect, recycler, false);
        Log.d(TAG,"the i ="+i+" visible count = "+visibleCount+",rect left = "+visibleRect.left);
    }
}

在进行横向移动时,在自定义LayoutManager中需要回收非显示区域的子视图,并且放置显示区域的子视图,相应代码如下

//横向移动的绝对距离
private int mScrollDistanceX = 0;
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //如视图内无可显示的子视图,不累加
    if(getChildCount() <= 0){
        return dx;
    }
    int travel = dx;
    //左边缘
    if(mScrollDistanceX + dx < 0){
        Log.d(TAG,"到达左边缘");
        travel = -mScrollDistanceX;
    }else if(mScrollDistanceX + dx > ((getItemCount() -1)*mChildIntervalWidth)){
        //右边缘
        Log.d(TAG,"到达右边缘");
        travel = (getItemCount() -1)*mChildIntervalWidth - mScrollDistanceX;
    }
    mScrollDistanceX += travel;
    //回收非显示区域子视图,并且在可见区域放置子视图
    Rect visibleRect = getVisibleArea();
    for(int i = getChildCount()-1; i >=0 ; i--){
        View item = getChildAt(i);
        int position = getPosition(item);
        Rect rect = mChildPositionRects.get(position);
        //判断子视图与可见区域无交集时,移除并回收视图
        if(!Rect.intersects(rect,visibleRect)){
            removeAndRecycleView(item,recycler);
            mChildHasAttached.put(position,false);
            Log.d(TAG,"移除视图 位置:"+position);
        }else{
            //可视区域放置子视图
            layoutDecoratedWithMargins(item , rect.left - mScrollDistanceX, rect.top ,
                    rect.right - mScrollDistanceX, rect.bottom);
            mChildHasAttached.put(position , true);
            Log.d(TAG,"放置视图 位置:"+position);
        }
    }
    //RecyclerView头尾填充空白区域
    View firstItem = getChildAt(0);
    View lastItem = getChildAt(getChildCount() - 1);
    if(travel >= 0 ){
        //左滑:向底部滑动
        int minPos = getPosition(firstItem);
        //填充可视区域右侧的View
        for(int i = minPos; i < getItemCount(); i++){
            insertView(i, visibleRect, recycler, false);
        }
    }else{
        //右滑:向顶部滑动
        int maxPos = getPosition(lastItem);
        //填充可视区域左侧的View
        for(int i = maxPos; i >= 0; i--){
            insertView(i, visibleRect, recycler, true);
        }
    }
    return travel;
}


//针对可视区域插入子视图
private void insertView(int pos , Rect visibleRect , RecyclerView.Recycler recycler , boolean firstPos){
    Rect rect = mChildPositionRects.get(pos);
    if(Rect.intersects(visibleRect , rect) && !mChildHasAttached.get(pos)){
        //仅在可视区域其未显示的视图才执行插入视图
        View item = recycler.getViewForPosition(pos);
        if(firstPos){
            addView(item , 0);
        }else{
            addView(item);
        }
        measureChildWithMargins(item,0,0);
        layoutDecoratedWithMargins(item, rect.left - mScrollDistanceX, rect.top ,
                rect.right - mScrollDistanceX, rect.bottom);
        mChildHasAttached.put(pos, true);
    }
}

至此已经可以做到展示1中的非3D画廊效果

实现滑动过程3D旋转效果

实现3D旋转效果主要在于计算旋转角度,先了解下Android的三维坐标系,详细图示如下图所示。对于围绕坐标轴旋转的情况,顺时针为正向,逆时针为负向。
在这里插入图片描述
要实现图示的效果,关键在于设置子视图围绕y方向的旋转,其思路如下两侧视图距离中心视图的距离offsetX越大,旋转角度越大。且offsetX为负时,旋转角度为正。为了避免UI效果明显变形,实际操作过程中要限定最大变换角度。
在这里插入图片描述
在自定义RecyclerView中计算旋转角度的代码如下

private final float MAX_ROTATION_Y = 20.0f;
//根据与中心点的距离计算y轴旋转角度,距离越远旋转越大
private float calculateRotationY(int offsetX){
    float rotation = -MAX_ROTATION_Y * offsetX / mIntervalDistance;
    if(rotation < -MAX_ROTATION_Y){
        rotation = -MAX_ROTATION_Y;
    }else if(rotation > MAX_ROTATION_Y){
        rotation = MAX_ROTATION_Y;
    }
    return rotation;
}

设置子视图的旋转则是在自定义RecyclerView中重写drawChild(Canvas canvas, View child, long drawingTime)方法实现,其代码较为简单,贴上代码如下

/**
 * 设置子视图的缩放系数/旋转角度
 * @param canvas
 * @param child
 * @param drawingTime
 * @return
 */
@Override
public boolean drawChild(Canvas canvas, View child, long drawingTime) {
    int childWidth = child.getWidth() - child.getPaddingLeft() - child.getPaddingRight();
    int childHeight = child.getHeight() - child.getPaddingTop() - child.getPaddingBottom();
    int width = getWidth();
    if(width <= child.getWidth()){
        return super.drawChild(canvas, child, drawingTime);
    }
    int pivot = (width - childWidth)/2;
    int x = child.getLeft();
    float scale , alpha;
    alpha = 1 - 0.6f*Math.abs(x - pivot)/pivot;
    if(x <= pivot){
        scale = 2f*(1-mSelectedScale)*(x+childWidth) / (width+childWidth) + mSelectedScale;
    }else{
        scale = 2f*(1-mSelectedScale)*(width - x) / (width+childWidth) + mSelectedScale;
    }
    child.setPivotX(childWidth / 2);
    child.setPivotY(childHeight*2 / 5);
    child.setScaleX(scale);
    child.setScaleY(scale);
    float rotationY = calculateRotationY(x - pivot);
    if(Math.abs(x - pivot) < 5){
        child.setRotationY(0);
        rotationY = 0;
    }else {
        child.setRotationY(rotationY);
    }
    return super.drawChild(canvas, child, drawingTime);
}

至此3D画廊效果完全实现了

代码使用

实现本文所述效果只是针对LayoutManager和RecyclerView进行了自定义,复制该两个文件至项目中即可实现初步效果。相关代码已经上传至Gitee中

https://gitee.com/com_mailanglidegezhe/solid_gallery.git

Bug修复记录

1. 快速滑动场景下右侧内容空白问题

问题原因:快速滑动场景下,添加子视图触发了onLayoutChildren逻辑重走,该场景下子视图位置并未重置,无法满足重新布局的要求。(在此特别感谢热心网友提供的线索)
问题对策:在onLayoutChildren方法执行时增加判断当前布局是否已经layout,如果已经layout直接返回即可

学习心得

最后还是要感谢启舰著的《Android自定义控件高级进阶与精彩实现》书中第8章给予的技术培训,本文所述的主要方法和思想来自于本书的指引。由于时间和能力水平限制,目前该3D画廊效果截至目前存在的快速滑动时偶发子视图放置逻辑失效的问题已经优化。如有其他待优化的问题,欢迎大家指正。

  • 7
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 31
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值