利用 RecyclerView.ItemDecoration 实现分组与标题吸顶效果

11 篇文章 0 订阅

RecyclerView 不同组别标题的吸顶效果图如下:

请添加图片描述

该效果是通过自定义 ItemDecoration 实现的,先了解一下 ItemDecoration 的工作原理。

1、ItemDecoration

ItemDecoration 允许应用给具体的 View 添加具体的图画或者 layout 的偏移,对于绘制 View 之间的分割线,视觉分组边界等等是非常有用的。当我们调用 addItemDecoration() 添加 Decoration 的时候,RecyclerView 就会调用该类的 onDraw() 去绘制分隔线,也就是说:分隔线是绘制出来的。

RecyclerView.ItemDecoration,该类为抽象类,官方目前只提供了一个实现类 DividerItemDecoration。

1.1 ItemDecoration 源码

RecyclerView 的抽象静态内部类 ItemDecoration 负责管理 RecyclerView 中各个 ItemView 的装饰(如分隔线、高亮显示、可视化分组边界):

    /**
    * ItemDecoration 允许向 Adapter 中的数据集中的特定项视图添加特殊的绘图和布局
    * 偏移量,这对于在 Item 之间绘制分隔线、高亮显示、可视化分组边界等都很有用。
    * 所有的 ItemDecoration 都按照添加的顺序绘制,其中 onDraw() 是在 ItemView 
    * 之前绘制,onDrawOver() 是在 ItemView 之后绘制
    */
    public abstract static class ItemDecoration {
        /**
         * onDraw 会先于 ItemView 绘制,如果二者绘制的内容由重叠区域,
         * onDraw 绘制的内容会被 ItemView 的内容覆盖
         */
        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) {
        }

        /**
         * 在 ItemView 之后进行绘制,绘制内容在 ItemView 上面
         */
        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);
        }

        /**
         * 检索给定 Item 的偏移量,单位是px。outRect 会保存结果数据,其每个  
         * 字段都指定了 Item View 应该被嵌入的像素值,类似于 padding 或 
         * margin。默认实现是将 outRect 的边界设为 0
         * 如果这个 ItemDecoration 不影响 ItemView 的位置,需要将 outRect 的四个
         * 字段设置为 0
         *
         * 如果需要获取 Adapter 中的额外数据,可以调用通过
         * RecyclerView#getChildAdapterPosition(View) 来获取 Adapter 的位置
         */
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                @NonNull RecyclerView parent, @NonNull State state) {
            getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                    parent);
        }
    }

ItemDecoration 有两个需要注意的地方:

  1. getItemOffsets() 中的 outRect 用来指定绘制 ItemDecoration 的预留空间
  2. ItemDecoration 与 RecyclerView 中的 ItemView 的绘制顺序:ItemDecoration#onDraw() -> ItemView#onDraw() -> ItemDecoration#onDrawOver(),先执行绘制的方法,其绘制内容在较下层,会被覆盖

getItemOffsets() 与 outRect

其实 RecyclerView 中的每个 ItemView 外都有一个 outRect 用以指定在 left、top、right 和 bottom 四个方向上的预留空间(下图灰色区域):

源码中,当我们调用 RecyclerView 的 addItemDecoration() 时会通过 requestLayout() 进行重新布局:

	public void addItemDecoration(@NonNull ItemDecoration decor, int index) {
        ...
        if (mItemDecorations.isEmpty()) {
            setWillNotDraw(false);
        }
        if (index < 0) {
            mItemDecorations.add(decor);
        } else {
            mItemDecorations.add(index, decor);
        }
        markItemDecorInsetsDirty();
        requestLayout();
    }

当测量到 RecyclerView 时会调用其 LayoutManager 的 measureChildWithMargins() 进行测量:

        public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            
            // 获取 child 的 outRect 的测量结果,并分别在宽度和高度上累加
            final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
            widthUsed += insets.left + insets.right;
            heightUsed += insets.top + insets.bottom;

            // RecyclerView 的左右 padding,以及 child 的左右 margin 再加上包括
            // 所有 outRect 在水平方向上占用的空间之和,作为已经被使用的空间,在
            // 计算 child 的 MeasureSpec 时会从总宽度中减掉
            final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
                    getPaddingLeft() + getPaddingRight()
                            + lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
                    canScrollHorizontally());
            // 高度计算与宽度类似
            final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
                    getPaddingTop() + getPaddingBottom()
                            + lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
                    canScrollVertically());
            if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
                child.measure(widthSpec, heightSpec);
            }
        }

RecyclerView 通过 getItemDecorInsetsForChild() 对每个 ItemView 的 outRect 占用空间进行计算:

    // 由于每个 ItemView 可以有多个 ItemDecoration 修饰,所以把它们存在列表中
    final ArrayList<ItemDecoration> mItemDecorations = new ArrayList<>();
    
    Rect getItemDecorInsetsForChild(View child) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (!lp.mInsetsDirty) {
            return lp.mDecorInsets;
        }

        if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
            // changed/invalid items should not be updated until they are rebound.
            return lp.mDecorInsets;
        }
        // 计算结果也用一个 Rect 表示,初始化为(0, 0, 0, 0)
        final Rect insets = lp.mDecorInsets;
        insets.set(0, 0, 0, 0);
        final int decorCount = mItemDecorations.size();
        // 对当前 child 上所有的 ItemDecoration 进行四个方向上的累加计算
        for (int i = 0; i < decorCount; i++) {
            mTempRect.set(0, 0, 0, 0);
            // 调用 getItemOffsets() 获取 outRect 信息
            mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
            insets.left += mTempRect.left;
            insets.top += mTempRect.top;
            insets.right += mTempRect.right;
            insets.bottom += mTempRect.bottom;
        }
        lp.mInsetsDirty = false;
        return insets;
    }

也就是说,getItemOffsets() 中由 outRect 指定的每一个 Item 的预留空间,都会在 RecyclerView 进行测量时,被计算在内。

onDraw() 与 onDrawOver()

当 RecyclerView 进行绘制时,它的父 ViewGroup 的 dispatchDraw() 会调用到 drawChild() 开始绘制子控件:

#ViewGroup
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        return child.draw(canvas, this, drawingTime);
    }

这样就来到了 RecyclerView 当中:

#RecyclerView

    @Override
    public void draw(Canvas c) {
        super.draw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDrawOver(c, this, mState);
        }
        ……
    }

先 super 去到父类的 draw():

#View
    
    // 不是本文重点,省略绝大部分代码
    public void draw(Canvas canvas) {
        /*
         * Draw traversal performs several drawing steps which must be executed
         * in the appropriate order:
         *
         *      1. Draw the background
         *      2. If necessary, save the canvas' layers to prepare for fading
         *      3. Draw view's content
         *      4. Draw children
         *      5. If necessary, draw the fading edges and restore layers
         *      6. Draw decorations (scrollbars for instance)
         *      7. If necessary, draw the default focus highlight
         */
         
         // Step 3, draw the content
        onDraw(canvas);

        // Step 4, draw the children
        dispatchDraw(canvas);
    }

执行到 Step3 就会调用到 RecyclerView 的 onDraw():

#RecyclerView

    @Override
    public void onDraw(Canvas c) {
        super.onDraw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDraw(c, this, mState);
        }
    }

在这里,先调用了 ItemDecorations 的 onDraw()。然后回到 View 中的 Step4,又会执行到 ViewGroup 的 dispatchDraw(),这一次再分发绘制事件,其实就是 RecyclerView 分发去绘制各个 ItemView 的,这是个递归过程,不再赘述。只需知道,经过这一步,ItemView 都绘制完成了。

代码再向上返回,RecyclerView draw() 的 super.draw() 执行完了,接着就会再遍历 mItemDecorations 执行 ItemDecoration 的 onDrawOver() 了。

由此可见,绘制的先后顺序就是 ItemDecoration#onDraw() -> ItemView#onDraw() -> ItemDecoration#onDrawOver(),先绘制的显示在下层,后绘制的在上层,如果绘制出现了重叠的部分,当然就是在上层的优先显示啦:

请添加图片描述

到这里 ItemDecoration 中两个重要的知识就说完了,这两点也是我们实现 RecyclerView 标题吸顶的关键。

1.2 DividerItemDecoration

我们再看看系统是如何实现 ItemDecoration 的。系统提供了一个 DividerItemDecoration 用来绘制 Item 之间的分隔线。首先在 getItemOffsets() 中会在绘制方向上预留出一个分割线占用的空间大小:

    @Override
    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) {
            // 竖直方向布局时,在底部留出一个 mDivider 的高度
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            // 水平方向布局时,在右侧留出一个 mDivider 的宽度
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }

再根据 RecyclerView.LayoutManager 中指定的方向(竖直 or 水平),绘制分隔线:

    // 分隔线,用 Drawable 画
    private Drawable mDivider;
    
    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (parent.getLayoutManager() == null || mDivider == null) {
            return;
        }
        // mOrientation 方向由 RecyclerView.LayoutManager 指定
        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;
        
        // 先看 RecyclerView 中的布尔成员 mClipToPadding,为 true 则表示
        // View 不能在 padding 区域内进行绘制。可以通过布局中的 
        // android:clipToPadding 属性进行设置,默认为 true
        if (parent.getClipToPadding()) {
            // 对 canvas 进行剪裁,边界将 padding 刨除在外
            left = parent.getPaddingLeft();
            right = parent.getWidth() - parent.getPaddingRight();
            canvas.clipRect(left, parent.getPaddingTop(), right,
                    parent.getHeight() - parent.getPaddingBottom());
        } else {
            // 如果可以在 padding 区域内绘制,那么绘制区域不变
            left = 0;
            right = parent.getWidth();
        }

        // 计算出每一个 child 的分隔线的位置,并画出分隔线
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            // 获取装饰边界 mBounds
            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() 确定绘制区域。该方法确定是否要将绘制的 canvas 根据父容器的 padding 进行剪裁。如果该方法返回 true,意味着父布局不允许你在 padding 内进行绘制,那么就要将 canvas 减去相应的 padding。

确定好绘制范围后,再计算出每个 ItemView 要绘制内容的位置。以 DividerItemDecoration 为例,获取到装饰边界的 mBounds 后,先确定底部坐标 bottom,再用 bottom 减去分隔线高度得到 top,left 与 right 前面已经确定好了,这样用来绘制分隔线的矩形的四边就确定了,最后调用 Drawable 的绘制方法即可。

2、实现 RecyclerView 标题吸顶效果

先说一下准备工作,RecyclerView 要显示的数据做成 JavaBean:

public class Data {

    private String name;
    private String groupName; // 分组名称
    
    // getters and setters...   
}

RecyclerView 的 Adapter 中要提供一个根据位置判断当前 Item 是否为分组的第一个元素:

public class DataAdapter extends RecyclerView.Adapter<DataAdapter.DataViewHolder> {

    private Context context;
    private List<Data> list;

    public DataAdapter(Context context, List<Data> list) {
        this.context = context;
        this.list = list;
    }
    
    public boolean isGroupHeader(int position) {
        if (position == 0) {
            return true;
        }

        // 如果当前位置的 GroupName 和上一个位置的 GroupName 不同即为 GroupHeader
        String currentGroupName = getGroupName(position);
        String previousGroupName = getGroupName(position - 1);
        return !currentGroupName.equals(previousGroupName);
    }

    public String getGroupName(int position) {
        return list.get(position).getGroupName();
    }
    
    // ViewHolder 就是一个 TextView 显示 Data 的 name 字段的,省略...
}

下面就开始实现自定义的 ItemDecoration。先重写 getItemOffsets(),给每个 Item 的预留空间设置好:

public class DataItemDecoration extends RecyclerView.ItemDecoration {

    private static final int DIVIDER_HEIGHT_IN_PX = 1;
    
    private int groupHeaderHeight;
    private Paint backgroundPaint;
    private Paint textPaint;

    public DataItemDecoration(Context context) {
        groupHeaderHeight = dp2px(context, 60);

        // 头部背景画笔
        backgroundPaint = new Paint();
        backgroundPaint.setColor(Color.RED);

        // 头部文字画笔
        textPaint = new Paint();
        textPaint.setTextSize(50);
        textPaint.setColor(Color.WHITE);
    }
    
    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

        if (parent.getAdapter() instanceof DataAdapter) {
            DataAdapter adapter = (DataAdapter) parent.getAdapter();
            int position = parent.getChildLayoutPosition(view);
            if (adapter.isGroupHeader(position)) {
                // 如果是头部,就在上方预留出 Header 的高度
                outRect.set(0, groupHeaderHeight, 0, 0);
            } else {
                // 如果不是头部,则在上方留出 1px画分隔线
                outRect.set(0, DIVIDER_HEIGHT_IN_PX, 0, 0);
            }
        }
    }
}

查看效果发现在每个分组的第一个 Item 上方会出现一个白色区域,即是 outRect:

在 onDraw() 中,不论是在 outRect 中绘制,还是在其余 Item 上方绘制分隔线时,都需要计算绘制区域的边界。以图中 outRect 为例,它的 bottom 就是 Item00 的 top,在已知 outRect 高度为 groupHeaderHeight 的情况下,outRect 的 top 就是 ItemView00.getTop() - groupHeaderHeight。代码如下:

    /**
     * 所有 GroupHeader 的内容都使用 onDraw() 绘制
     */
    @Override
    public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDraw(canvas, parent, state);

        if (parent.getAdapter() instanceof DataAdapter) {
            DataAdapter adapter = (DataAdapter) parent.getAdapter();
            // 获取当前屏幕可见的 Item 数量
            int childCount = parent.getChildCount();
            // 确定 canvas 的左右边界,刨除 padding
            int left = parent.getPaddingLeft();
            int right = parent.getWidth() - parent.getPaddingRight();
            for (int i = 0; i < childCount; i++) {
                View child = parent.getChildAt(i);
                int position = parent.getChildLayoutPosition(child);
                if (adapter.isGroupHeader(position)) {
                    // 绘制 GroupHeader 的背景
                    canvas.drawRect(left, child.getTop() - groupHeaderHeight, right, child.getTop(), backgroundPaint);
                    String groupName = adapter.getGroupName(position);
                    // 绘制 GroupHeader 的文字
                    canvas.drawText(groupName, left + 20, getTextBaselineY(textPaint, child.getTop(), groupHeaderHeight), textPaint);
                } else {
                    // 不是 GroupHeader 就画一个分隔线
                    canvas.drawRect(left, child.getTop() - DIVIDER_HEIGHT_IN_PX, right, child.getTop(), backgroundPaint);
                }
            }
        }
    }

顶置的 GroupHeader 需要用 onDrawOver() 绘制(因为要显示在最上方,覆盖 ItemView 和 onDraw() 绘制的 GroupHeader),并且要实现一个被新来的 GroupHeader 向上推的效果:

请添加图片描述

这种效果其实只需要让顶置的 GroupHeader 的 bottom 不断变小就能做到,关键问题是什么时候开始变?变化的数值如何计算?

我们的思路是(有个前提,GroupHeader 的高度与 ItemView 的高度是一样的,在例子中都被设置为 60dp):

  1. 先找到 RecyclerView 中第一个可见 Item 在 Adapter 中的位置
  2. 向上滑动到第二个可见的 Item 是一个 GroupHeader 的时候,就要开始减小顶置的 GroupHeader 的 bottom 值了,这样才能实现第二个 GroupHeader 向上推顶置 GroupHeader 的效果
  3. 顶置的 GroupHeader 的 bottom 变化应该跟随第一个可见 Item 的可见部分高度变化(向上滑动的过程中,第一个可见 Item 刚好被顶置的 GroupHeader 完全遮盖,其可见部分高度逐渐变小,让 GroupHeader 的 bottom 也跟随它变化,保持一致即可)

代码如下:

    /**
     * 吸顶的 GroupHeader 用 onDrawOver() 绘制,就会遮挡 ItemView 和
     * 用 onDraw() 绘制的 GroupHeader
     */
    @Override
    public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(canvas, parent, state);

        if (parent.getAdapter() instanceof DataAdapter) {
            DataAdapter adapter = (DataAdapter) parent.getAdapter();

            // 获取 RecyclerView 中第一个可见 Item 的位置以及对应的 View
            int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
            View itemView = parent.findViewHolderForAdapterPosition(position).itemView;
            String groupName = adapter.getGroupName(position);

            // 计算边界,其中 top 应该是 RecyclerView 的 paddingTop
            int left = parent.getPaddingLeft();
            int right = parent.getWidth() - parent.getPaddingRight();
            int top = parent.getPaddingTop();

            // 此时的临界条件是:第二个可见的 Item 为 GroupHeader
            boolean isGroupHeader = adapter.isGroupHeader(position + 1);

            // 计算两种情况下,顶置的 GroupHeader 的 bottom 以及文字 BaseLine 的 Y 轴坐标
            int bottom;
            int baseLineY;
            if (isGroupHeader) {
                // 到达临界条件,顶置 GroupHeader 的 top 不变,但是 bottom 要动态计算
                int firstItemVisibleHeight = Math.min(groupHeaderHeight, itemView.getBottom());
                bottom = firstItemVisibleHeight + top;
                baseLineY = getTextBaselineY(textPaint, bottom, groupHeaderHeight);
            } else {
                // 没到临界条件就按照固定位置画
                bottom = top + groupHeaderHeight;
                baseLineY = getTextBaselineY(textPaint, top + groupHeaderHeight, groupHeaderHeight);
            }

            canvas.drawRect(left, top, right, bottom, backgroundPaint);
            canvas.drawText(groupName, left + 20, baseLineY, textPaint);
        }
    }

到这基本上算实现了这个功能,但是有一个 bug,我们在布局中给 RecyclerView 加一个 paddingTop 属性,再来看效果:

请添加图片描述

可以看到主要有两个问题:

  1. 白色背景的 paddingTop 部分不应该被绘制
  2. 顶置的 GroupHeader 被向上推的效果没有了

问题 1,多余的绘制部分是由 onDraw() 绘制的,看代码发现,在绘制时并没有对绘制区域的合法性进行检查,应该加上:

    @Override
    public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDraw(canvas, parent, state);

        if (parent.getAdapter() instanceof DataAdapter) {
            DataAdapter adapter = (DataAdapter) parent.getAdapter();
            int childCount = parent.getChildCount();

            int left = parent.getPaddingLeft();
            int right = parent.getWidth() - parent.getPaddingRight();
            for (int i = 0; i < childCount; i++) {
                View child = parent.getChildAt(i);
                int position = parent.getChildLayoutPosition(child);

                // 新增代码,先判断是否会在 RecyclerView 的 paddingTop 区域内
                // 绘制,如果是就跳过该 child 的绘制过程。
                int minTop = Math.min(child.getTop() - groupHeaderHeight, child.getTop() - DIVIDER_HEIGHT_IN_PX);
                if (minTop < parent.getPaddingTop()) {
                    continue;
                }

                if (adapter.isGroupHeader(position)) {
                    canvas.drawRect(left, child.getTop() - groupHeaderHeight, right, child.getTop(), backgroundPaint);
                    String groupName = adapter.getGroupName(position);
                    canvas.drawText(groupName, left + 20, getTextBaselineY(textPaint, child.getTop(), groupHeaderHeight), textPaint);
                } else {
                    canvas.drawRect(left, child.getTop() - DIVIDER_HEIGHT_IN_PX, right, child.getTop(), backgroundPaint);
                }
            }
        }
    }

问题 2 的原因是在给 RecyclerView 增加了 paddingTop 后,第一个可见 Item 的可见高度在计算时也应该相应的减掉 paddingTop 的数值:

    @Override
    public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(canvas, parent, state);

        if (parent.getAdapter() instanceof DataAdapter) {
            DataAdapter adapter = (DataAdapter) parent.getAdapter();

            int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
            View itemView = parent.findViewHolderForAdapterPosition(position).itemView;
            String groupName = adapter.getGroupName(position);

            int left = parent.getPaddingLeft();
            int right = parent.getWidth() - parent.getPaddingRight();
            int top = parent.getPaddingTop();

            boolean isGroupHeader = adapter.isGroupHeader(position + 1);

            int bottom;
            int baseLineY;
            if (isGroupHeader) {
                int firstItemVisibleHeight = Math.min(groupHeaderHeight, itemView.getBottom() - parent.getPaddingTop()); // 更新计算方式
                bottom = firstItemVisibleHeight + top;
                baseLineY = getTextBaselineY(textPaint, bottom, groupHeaderHeight);

            } else {
                bottom = top + groupHeaderHeight;
                baseLineY = getTextBaselineY(textPaint, top + groupHeaderHeight, groupHeaderHeight);

            }

            canvas.drawRect(left, top, right, bottom, backgroundPaint);
            canvas.drawText(groupName, left + 20, baseLineY, textPaint);
        }
    }

更改后的效果图:

请添加图片描述

完整代码:GitHub

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值