如何应对Android面试官->我用RecyclerView实现了吸顶效果

发布时间:2024年01月15日

前言

RecyclerView 计划用两个章节来讲解,今天主要是以 itemDecoration 和 实现吸顶效果为主;

ItemDecoration

ItemDecoration 允许应用给具体的 View 添加具体的图画或者 Layout 的偏移,对于绘制 View 之间的分割线,视觉分组边界等等是非常有用;

当我们调用 RecyclerView 的 addItemDecoration 添加 decoration 的时候,RecyclerView 就会调用该类的 onDraw 方法去绘制分割线,也就是说分割线是绘制出来的;

RecyclerView.ItemDecoration 类是抽象类;

public abstract static class ItemDecoration {    
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {        
        onDraw(c, parent);    
    }    
     @Deprecated    
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent) {    
    }    
    public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,            
            @NonNull State state) {        
        onDrawOver(c, parent);    
    }    
    @Deprecated    
    public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent) {    
    }    
    @Deprecated    
    public void getItemOffsets(@NonNull Rect outRect, int itemPosition,            
            @NonNull RecyclerView parent) {        
        outRect.set(0, 0, 0, 0);    
    }    
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,            
            @NonNull RecyclerView parent, @NonNull State state) {        
        getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),                
            parent);    
    }
}

Android 官方只提供了一个实现类 DividerItemDecoration;我们先来简单看下它是如何实现的;

根据注释我们可以知道,DividerItemDecoration 需要结合 LinearLayoutManager 一起使用,以及它是如何创建并和 RecycerlView 如何绑定的;

我们进入构造方法看下:

可以看到,分割线其实就是一个 Drawable,我们也可以通过 setDrawable 方法自定义一个 Drawable 来定义我们的分割线;

public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {    
    if (parent.getLayoutManager() == null || mDivider == null) {        
        return;    
    }
    //  绘制的时候,根据方向,绘制不同的分割线    
    if (mOrientation == VERTICAL) {        
        drawVertical(c, parent);    
    } else {        
        drawHorizontal(c, parent);    
    }
}

我们进入这两个方法,分别看下:

private void drawVertical(Canvas canvas, RecyclerView parent) {    
    canvas.save();    
    final int left;    
    final int right;    
    //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.    
    if (parent.getClipToPadding()) {        
        left = parent.getPaddingLeft();        
        right = parent.getWidth() - parent.getPaddingRight();        
        canvas.clipRect(left, parent.getPaddingTop(), right,                
        parent.getHeight() - parent.getPaddingBottom());    
    } else {        
        left = 0;        
        right = parent.getWidth();    
    }    
    final int childCount = parent.getChildCount();    
    for (int i = 0; i < childCount; i++) {        
        final View child = parent.getChildAt(i);        
        parent.getDecoratedBoundsWithMargins(child, mBounds);        
        final int bottom = mBounds.bottom + Math.round(child.getTranslationY());        
        final int top = bottom - mDivider.getIntrinsicHeight();        
        mDivider.setBounds(left, top, right, bottom);        
        mDivider.draw(canvas);    
    }    
    canvas.restore();
}

可以看到,如果 parent.getClipToPadding() 为 true 的话,RecyclerView 的 padding 区域是可以绘制分割线的,否则就可以绘制;用来获取 left 和 right;

然后获取 bottom 和 top,最后调用 Drawable 的 darw 方法,进行绘制;

drawHorizontal 方法,大家可以自行看下;我们来看下 getItemOffsets 方法

public void getItemOffsets(Rect outRect, View view, RecyclerView parent,        
        RecyclerView.State state) {    
    if (mDivider == null) {        
        outRect.set(0, 0, 0, 0);        
        return;    
    }    
    if (mOrientation == VERTICAL) {        
        outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());    
    } else {        
        outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);    
    }
}

这个 outRect 就是在 item 的四周留出指定的间隙;可以看下面这张图来加深下理解:

ItemDecoration 提供了 onDraw 和 onDrawOver 方法,这两个方法有什么区别呢?

本质上来说,onDraw 方法的绘制区域,可能会被 item 遮挡,onDrawOver 的绘制区域不会被 item 遮挡;

onDraw 的绘制区域:

onDrawOver 的绘制区域,它会在 itemview 绘制之后才进行绘制:

接下来,我们来一步一步撸码实现吸顶效果,我们先来搭一个简单的架子:

public class NBAStarDecoration extends RecyclerView.ItemDecoration {        
    NBAStarDecoration(Context context) {            
    }    
    @Override    
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {                
        super.onDraw(c, parent, state);    
    }    
    @Override    
    public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {        
        super.onDrawOver(c, parent, state);    
    }    
    @Override    
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {        
        super.getItemOffsets(outRect, view, parent, state);            
    }
}

我们如果想实现吸顶效果,那么就需要判断是不是头部,如果是头部,预留出吸顶的 View 空间,那么如何判断是不是头部呢?我们可以根据每组数据的组名来判断;

public class NBAStar {    
    private String name;    
    private String groupName;    
    public NBAStar(String name, String groupName) {        
        this.name = name;        
        this.groupName = groupName;    
    }    
    public String getName() {        
        return name;    
    }    
    public void setName(String name) {        
        this.name = name;    
    }    
    public String getGroupName() {        
        return groupName;    
    }    
    public void setGroupName(String groupName) {       
        this.groupName = groupName;    
    }
}

然后我们在 adapter 中根据 position 来判断当前位置是不是 groupName;

public class NBAStarAdapter extends RecyclerView.Adapter<NBAStarAdapter.NBAStarHolder> {    
    private Context context;    
    private List<NBAStar> starList;    
    public NBAStarAdapter(Context context, List<NBAStar> starList) {        
        this.context = context;        
        this.starList = starList;    
    }
    // 根据 position 来判断 当前 groupName 与前一个是不是相等;   
    public boolean isGroupHeader(int position) {        
        if (position == 0) {            
            return true;        
        } else {                
            String currentGroupName = getGroupName(position);            
            String preGroupName = getGroupName(position - 1);            
            if (TextUtils.equals(currentGroupName, preGroupName)) {                
                return false;            
            } else {                
                return true;            
            }        
        }        
    }    
    public String getGroupName(int position) {        
        return starList.get(position).getGroupName();    
    }
    // 
    ...
    // 省略部分代码
}

NBAStarDecoration 中根据这个判断来预留对应的空间

public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {    
    super.getItemOffsets(outRect, view, parent, state);    
    RecyclerView.Adapter adapter = parent.getAdapter();    
    if (adapter instanceof NBAStarAdapter) {        
        NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;        
        int position = parent.getChildLayoutPosition(view);        
        boolean groupHeader = nbaStarAdapter.isGroupHeader(position);        
        if (groupHeader) {            
            outRect.set(0, dp2px(100),0, 0);        
        } else {            
            outRect.set(0, 1, 0 , 0);        
        }    
    }
}

我们运行看下效果:

我们给头部预留出来了指定的位置;

接下里我们来绘制头部区域,绘制背景色和头部区域中的文字;

绘制背景色:

public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {    
    super.onDraw(canvas, parent, state);    
    RecyclerView.Adapter adapter = parent.getAdapter();    
    if (adapter instanceof NBAStarAdapter) {        
        NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;        
        int childCount = parent.getChildCount();        
        int left = parent.getPaddingLeft();        
        int right = parent.getWidth() - parent.getPaddingRight();        
        for (int i = 0; i < childCount; i++) {            
            View view = parent.getChildAt(i);            
            int position = parent.getChildLayoutPosition(view);            
            boolean groupHeader = nbaStarAdapter.isGroupHeader(position);            
            if (groupHeader) {                
                canvas.drawRect(new Rect(left, view.getTop() - dp2px(50), right, view.getTop()), paint);            
            }        
        }    
    }
}

我们运行看下效果:

可以看到,我们把头部颜色绘制了出来,接下来我们绘制头部的文字,也就是组名

String groupName = nbaStarAdapter.getGroupName(position);
textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
// 文字中心绘制,上一层有讲解
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
float centerHeight = view.getTop() - dp2px(50) / 2 + ((fontMetrics.descent - fontMetrics.ascent)/2) - fontMetrics.descent;
canvas.drawText(groupName, left + 20, centerHeight, textPaint);

文字中心绘制的原理可以查看上一章,如何应对Android面试官->文字中心绘制和颜色渐变,实战头条炫酷ViewPager指示器

可以看到,文字绘制了出来,但是还没达到我们想要的吸顶效果;

接下里我们来实现吸顶效果:

我们想要实现吸顶效果,那么我们需要在 onDrawOver 中实现,因为它的绘制是在 ItemView 之后,并且是固定的位置;我们需要拿到可见区域的第一个 item 的位置,并判断它是不是头部,以及当我们滑动的时候,当第二个头部的 top 滑动到第一个头部的 bottom 的位置的时候,第一个头部的 bottom 要缩小(移除屏幕),那么 bottom 什么时机开始变小呢?就是在我第一个头部的最后一个 itemView 的 getBottom 小于第一个头部的 bottom 的时候开始变小,具体实现如下:

@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {    
    super.onDrawOver(canvas, parent, state);    
    RecyclerView.Adapter adapter = parent.getAdapter();    
    if (adapter instanceof NBAStarAdapter) {        
        NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;        
        // 获取屏幕可见的第一个 View 的位置        
        int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();        
        // 获取这个位置的 itemView        
        View view = parent.findViewHolderForLayoutPosition(position).itemView;        
        int left = parent.getPaddingLeft();        
        int right = parent.getWidth() - parent.getPaddingRight();        
        int top = parent.getPaddingTop();        
        boolean groupHeader = nbaStarAdapter.isGroupHeader(position + 1);        
        if (groupHeader) {            
            // 判断是不是 itemView 的高度比 头部的高度小            
            int bottom = Math.min(dp2px(50), view.getBottom());            
            // 区域绘制            
            canvas.drawRect(left, top, right, bottom + top, paint);            
            // 文字绘制            
            String groupName = nbaStarAdapter.getGroupName(position);            
            textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
            // textRect.height()/2 可以替换成根据 fontMetrics 计算的高度,这里简约下            
            canvas.drawText(groupName, left + 20, top - dp2px(50) / 2 + textRect.height() / 2, textPaint);        
        } else {            
            // 区域绘制            
            canvas.drawRect(left, top, right, top + dp2px(50), paint);            
            // 文字绘制            
            String groupName = nbaStarAdapter.getGroupName(position);            
            textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
            // textRect.height()/2 可以替换成根据 fontMetrics 计算的高度,这里简约下            
            canvas.drawText(groupName, left + 20, top + dp2px(50) / 2 + textRect.height() / 2 , textPaint);        
        }    
    }
}

我们运行看下效果:

我们实现了吸顶效果,但是在滑动到顶部的时候,文字的滑出是有问题的,我们接着来看下;

说明我们的高度计算的不太对,应该是 top + bottom - 头部高度/2 + textRect.height()/2;

我们替换看下效果:

canvas.drawText(groupName, left + 20, 
    top + bottom - dp2px(50) / 2 + textRect.height() / 2, textPaint);

可以看到,没有问题了;

通常我们在使用 RecyclerView 的时候,可能会直接在 RecyclerView 上设置各种 margin 或者 padding,如果接下来,如果我们给 RecyclerView 设置一个 padding 的话,可能会有什么效果?

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"    
    android:layout_width="match_parent"    
    android:layout_height="match_parent">    
    <androidx.recyclerview.widget.RecyclerView        
        android:id="@+id/rv_list"        
        android:layout_width="match_parent"        
        android:layout_height="match_parent"        
        android:background="@color/colorPrimary"        
        android:paddingTop="150dp"/>
</RelativeLayout>

我们运行看下效果:

我们发现,顶部的 padding 区域不符合我们的预期,那么我们应该如何处理呢?

可以看到,这个 padding 内的内容,应该是 onDraw 绘制的头部区域,它应该消失掉,但是并没有,为什么不是 onDrawOver,因为 onDrawOver 绘制的区域是固定不动的;

也就是说我们需要在 onDraw 中处理,那么怎么处理呢?我们需要获取这段 padding 的距离并和 view 的 getTop 以及 头部的高度 之差做对比

也就是:

view.getTop() - dip2px(50) - parent.getPaddingTop() >= 0

所以,onDraw 中的判断规则改为:

if (groupHeader && view.getTop() - dp2px(50) - parent.getPaddingTop() >= 0) {}

我们运行看下效果:

达到了我们期望的效果,但是还有一个问题,就是 现役球星0 文字没有被推上去,我们需要在 onDrawOver 中处理一下,文字没有推上去,是 【现役球星0】的 bottom 没有改变导致的,也就是我们需要改变 bottom 的高度才行;

int bottom = Math.min(dp2px(50), view.getBottom() - parent.getPaddingTop());

我们需要减去这个 paddingTop 的值,我们运行看下效果:

可以看到,文字被推了上去,但是,文字推的优点过多了,进入了 padding 区域,我们需要 drawText 的时候限制绘制的区域才行,也就是我们需要减去这个? paddingTop 的距离 修改如下:

if (top + bottom - dp2px(50) / 2 - parent.getPaddingTop() >= 0) {    
    canvas.drawText(groupName, left + 20, top + bottom - dp2px(50) / 2 + textRect.height() / 2, textPaint);
}

运行看下效果:

文字可以被推上去了,并且也没有绘制到 paddingTop 区域,至此,我们的吸顶效果实现到这吧~~

简历润色

深度理解 ItemDecoration 实现原理,可自定义 ItemDecoration 的实现;

下一章预告

RecyclerView 的缓存复用原理和 LayoutManager 的实现原理解析

欢迎三连

来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~

文章来源:https://blog.csdn.net/IT_Android/article/details/135599028
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。