创建一个 RecyclerView LayoutManager – Part 1

发表于 5年以前  | 总阅读数:1436 次

创建一个 RecyclerView LayoutManager – Part 1

Building a RecyclerView LayoutManager – Part 1

本文是这个系列中的 Part 1,这里是 Part 2Part 3 的链接。

现如今,如果你是 Android 开发者,你肯定听说过 RecyclerView 这个控件; 它是一个即将加入 support library 之中的新组件, 通过方便的视图复用轻松实现自定义高效的视图集。 已经有很多优秀的文章介绍了 RecyclerView 基础,讲解如何使用 RecyclerView 提供的内建部分,包括 item animations。所以, 我们就不再重复前人的工作了,下面是一些帮你入门的资料:

我们的这个系列文章会关注 RecyclerView 底层细节,涉及到创建你自己的 LayoutManager,做些比单一的 垂直/水平 滚动列表稍稍复杂的东西。

在我们开始前,你需要知道 LayoutManager API 之所以能够让我们实现 强大复杂的布局是因为它只替你做了很少的工作;这就意味着 你不得不自己完成数量可观的代码。如果要在一个项目中使用自定义视图, 不要陷入过度优化或过度泛化代码之中。你只需要关心 在你的用例中需要实现的特性就可以了。

RecyclerView Playground

这个系列中所有的代码段都取自 RecyclerView Playground sample 这个项目, 示例应用里包含了各个方面使用 RecyclerView 的实例,从创建简单的 list 到自定义 LayoutManagers。

本文的代码来自 FixedGridLayoutManager ,一个可以垂直和水平 滚动的二维的网格布局。

support library 里也有一个自定义的 LayoutManager;本质上 是一个自定义 vertical linear list 的实现: [SDK_PATH]/extras/android/compatibility/samples/Support7Demos/src/com/example/android/supportv7/widget/RecyclerViewActivity.java 同时,Android L 和 新的 support libraries 可能还没加入 AOSP 之中,不过 RecyclerView 提供了 JAR 资源,可以在这里找到: [SDK_PATH]/extras/android/m2repository/com/android/support/recyclerview-v7/21.0.0-rc1/recyclerview-v7-21.0.0-rc1-sources.jar

The Recycler

首先,了解下 API 的结构。当你需要从一个可能再生的前子视图中 回收旧的 view 或者 获取新的 view 时, 你的 LayoutManager 可以访问一个 Recycler 实例。

Recycler 也免掉了直接访问 view 当前适配器方法的麻烦。当你的 LayoutManager 需要一个新的子视图时,只要调用 getViewForPosition() 这个方法,Recycler 会决定到底是从头创建一个新的视图 还是重用一个已存在的废弃视图。 你的 LayoutManager 需要及时将不再显示的视图传递给 Recycler, 避免 Recycler 创建不必要的 view 对象。

Detach vs. Remove

布局更新时有两个方法处理已存在的子视图:detach 和 remove (分离和移除)。Detach 是一个轻量的记录 view 操作。 被 detach 的视图在你的代码返回前能够重新连接。可以通过 Recycler 在不 重新绑定/重新构建 子视图的情况下修改已连接子视图的索引。

Remove 意味着这个 view 已经不需要了。任何被永久移除的 view 都应该 放到 Recycler 中,方便以后重用,不过 API 并没有强制要求。 被 remove 的视图是否被回收取决于你。

Scrap vs. Recycle

Recycler 有两级视图缓存系统: scrap heap 和 recycle pool (垃圾堆和回收池), Scrap heap 是一个轻量的集合,视图可以不经过适配器直接返回给 LayoutManager 。通常被 detach 但会在同一布局重新使用的视图会临时储存在这里。Recycle pool 存放的 是那些假定并没有得到正确数据(相应位置的数据)的视图, 因此它们都要经过适配器重新绑定后才能返回给 LayoutManager。

当要给 LayoutManager 提供一个新 view 时,Recycler 首先会 检查 scrap heap 有没有对应的 position/id;如果有对应的内容, 就直接返回数据不需要通过适配器重新绑定。如果没有的话, Recycler 就会从 recycle pool 里弄一个合适的视图出来, 然后用 adapter 给它绑定必要的数据 (就是调用 RecyclerView.Adapter.bindViewHolder()) 再返回。 如果 recycle pool 中也不存在有效 view ,就会在绑定数据前 创建新的 view (就是 RecyclerView.Adapter.createViewHolder()), 最后返回数据。

经验法则

只要你原意,LayoutManager 的 API 允许你独立完成所有这些任务, 所以可能的组合有点多。通常来说, 如果你想要临时整理并且希望稍后在同一布局中重新使用某个 view 的话, 可以对它调用 detachAndScrapView() 。如果基于当前布局 你不再需要某个 view 的话,对其调用 removeAndRecycleView()

Building The Core

LayoutManager 需要实时添加,测量和布局所有它需要的子视图。 当用户滚动屏幕时,布局管理器将来决定什么时候添加新的子视图, 什么时候可以 detach/scrap (分离/废弃)视图。

你需要实现下面这些方法创建一个可行的 LayoutManager 最小系统。

generateDefaultLayoutParams()

事实上你只要重写这个方法你的 LayoutManager 就能编译通过了。 实现也很简单,返回一个你想要默认应用给所有从 Recycler 中获得的子视图做参数的 RecyclerView.LayoutParams 实例。 这些参数会在对应的 getViewForPosition() 返回前赋值给相应的子视图。

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

onLayoutChildren()

onLayoutChildren() 是 LayoutManager 的主入口。 它会在 view 需要初始化布局时调用, 当适配器的数据改变时(或者整个适配器被换掉时)会再次调用。 注意!这个方法不是在每次你对布局作出改变时调用的。 它是 初始化布局 或者 在数据改变时重置子视图布局的好位置。

在接下来的部分,我们会分析在适配器更新时 是怎样使用它基于当前可见元素刷新布局的。 现在,我们将简单地解决这个问题当做子视图布局第一关。 下面是 FixedGridLayoutManager 示例的精简版:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //Scrap measure one child
    View scrap = recycler.getViewForPosition(0);
    addView(scrap);
    measureChildWithMargins(scrap, 0, 0);

    /*
     * We make some assumptions in this code based on every child
     * view being the same size (i.e. a uniform grid). This allows
     * us to compute the following values up front because they
     * won't change.
     */
    mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
    mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
    detachAndScrapView(scrap, recycler);

    updateWindowSizing();
    int childLeft;
    int childTop;

    /*
     * Reset the visible and scroll positions
     */
    mFirstVisiblePosition = 0;
    childLeft = childTop = 0;

    //Clear all attached views into the recycle bin
    detachAndScrapAttachedViews(recycler);
    //Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

我们会对子视图做一些记录和安排 (为了简便,假设来自适配器的所有子视图都是一样大的), 确保所有已存在的视图在 scrap heap 之中。我将 大部分工作抽象到 fillGrid() 这个辅助方法中以便重用。 我们很快就会看到这个方法在更新可见视图和滚动屏幕中被大量调用。

就像是自定义实现一个 ViewGroup,你负责触发测量和布局每一个 从 Recycler 获取到的子视图。API 没有直接完成这项工作 。

通常来说,在这类方法之中你需要完成的主要步骤如下:

  1. 在滚动事件结束后检查所有附加视图当前的偏移位置。
  2. 判断是否需要添加新视图填充由滚动屏幕产生的空白部分。并从 Recycler 中获取视图。
  3. 判断当前视图是否不再显示。移除它们并放置到 Recycler 中。
  4. 判断剩余视图是否需要整理。发生上述变化后可能 需要你修改视图的子索引来更好地和它们的适配器位置校准。

注意我们放进 FixedGridLayoutManager.fillGrid() 里填充 RecyclerView 的主要步骤。 当到达最大行数时,这个 manager 将位置从右到左排序,封装。

  1. 清点目前我们所有的视图。将他们 Detach 以便稍后重新连接。

     SparseArray<View> viewCache = new SparseArray<View>(getChildCount());
     //...
     if (getChildCount() != 0) {
         //...
         //Cache all views by their existing position, before updating counts
         for (int i=0; i < getChildCount(); i++) {
             int position = positionOfIndex(i);
             final View child = getChildAt(i);
             viewCache.put(position, child);
         }
         //Temporarily detach all views.
         // Views we still need will be added back at the proper index.
         for (int i=0; i < viewCache.size(); i++) {
             detachView(viewCache.valueAt(i));
         }
     }
  2. 测量/布局每一个当前可见的子视图。重新连接已有的视图很简单; 新的视图是从 Recycler 之中获取的。

     for (int i = 0; i < getVisibleChildCount(); i++) {
         //...
    
         //Layout this position
         View view = viewCache.get(nextPosition);
         if (view == null) {
             /*
              * The Recycler will give us either a newly constructed view,
              * or a recycled view it has on-hand. In either case, the
              * view will already be fully bound to the data by the
              * adapter for us.
              */
             view = recycler.getViewForPosition(nextPosition);
             addView(view);
    
             /*
              * It is prudent to measure/layout each new view we
              * receive from the Recycler. We don't have to do
              * this for views we are just re-arranging.
              */
             measureChildWithMargins(view, 0, 0);
             layoutDecorated(view, leftOffset, topOffset,
                     leftOffset + mDecoratedChildWidth,
                     topOffset + mDecoratedChildHeight);
         } else {
             //Re-attach the cached view at its new index
             attachView(view);
             viewCache.remove(nextPosition);
         }
    
         //...
     }
    
  3. 最终,所有在第一步中 detach 并且没有被重新连接的视图都不可见。 将它们移入 Recycler 中,以备后用。

     for (int i=0; i < viewCache.size(); i++) {
         recycler.recycleView(viewCache.valueAt(i));
     }

说明一下,先将所有视图 detach 之后再将需要的视图重新连接是为了 保持每一个视图子索引的顺序 (就是 getChildAt() 的索引)。我们希望 可见视图从左上到右下的索引从 0 开始,到 getChildCount()-1 结束。 当我们上下滑动视图,新的子视图被添加,它的索引顺序会变得不可靠。 我们需要保留正确的索引来在任意点上定位每一个视图。在一个简单地 LayoutManager (比如 LinearLayoutManager)中,子视图可以轻松地插入 list 的两端, 记录层就没有存在的必要了。

添加用户交互

目前,我们已经有一个非常好的初始布局,但是它并不能动起来。 RecyclerView 的关键就在于当用户浏览一组数据时动态提供视图。 覆盖一些方法就能实现我们的目的。

canScrollHorizontally() & canScrollVertically()

这些方法很简单,在你想要滚动方向对应的方法里返回 true , 不想要滚动方向对应的方法里返回 false。

@Override
public boolean canScrollVertically() {
    //We do allow scrolling
    return true;
}

scrollHorizontallyBy() & scrollVerticallyBy()

在这里你应该实现 content 移动的逻辑。RecyclerView 已经处理了 scrolling 和 flinging (注:Fling: Gross gesture, no on-screen target) 触摸操作,不需要处理 MotionEvents 或者 GestureDetectors 这些麻烦事。 你只需要完成下面这三个任务:

  1. 将所有的子视图移动适当的位置 (对的,你得自己做这个)。
  2. 决定移动视图后 添加/移除 视图。
  3. 返回滚动的实际距离。框架会根据它判断你是否触碰到边界。

在 FixedGridLayoutManager 里,这两个方法很像。这里是精简后的垂直滚动实现:

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
        RecyclerView.State state) {

    if (getChildCount() == 0) {
        return 0;
    }

    //Take top measurements from the top-left child
    final View topView = getChildAt(0);
    //Take bottom measurements from the bottom-right child.
    final View bottomView = getChildAt(getChildCount()-1);

    //Optimize the case where the entire data set is too small to scroll
    int viewSpan = getDecoratedBottom(bottomView) - getDecoratedTop(topView);
    if (viewSpan <= getVerticalSpace()) {
        //We cannot scroll in either direction
        return 0;
    }

    int delta;
    int maxRowCount = getTotalRowCount();
    boolean topBoundReached = getFirstVisibleRow() == 0;
    boolean bottomBoundReached = getLastVisibleRow() >= maxRowCount;

    if (dy > 0) { // Contents are scrolling up
        //Check against bottom bound
        if (bottomBoundReached) {
            //If we've reached the last row, enforce limits
            int bottomOffset;
            if (rowOfIndex(getChildCount() - 1) >= (maxRowCount - 1)) {
                //We are truly at the bottom, determine how far
                bottomOffset = getVerticalSpace() - getDecoratedBottom(bottomView)
                        + getPaddingBottom();
            } else {
                /*
                 * Extra space added to account for allowing bottom space in the grid.
                 * This occurs when the overlap in the last row is not large enough to
                 * ensure that at least one element in that row isn't fully recycled.
                 */
                bottomOffset = getVerticalSpace() - (getDecoratedBottom(bottomView)
                        + mDecoratedChildHeight) + getPaddingBottom();
            }
            delta = Math.max(-dy, bottomOffset);
        } else {
            //No limits while the last row isn't visible
            delta = -dy;
        }
    } else { // Contents are scrolling down
        //Check against top bound
        if (topBoundReached) {
            int topOffset = -getDecoratedTop(topView) + getPaddingTop();
            delta = Math.min(-dy, topOffset);
        } else {
            delta = -dy;
        }
    }

    offsetChildrenVertical(delta);

    if (dy > 0) {
        if (getDecoratedBottom(topView) < 0 && !bottomBoundReached) {
            fillGrid(DIRECTION_DOWN, recycler);
         } else if (!bottomBoundReached) {
            fillGrid(DIRECTION_NONE, recycler);
         }
    } else {
        if (getDecoratedTop(topView) > 0 && !topBoundReached) {
            fillGrid(DIRECTION_UP, recycler);
        } else if (!topBoundReached) {
            fillGrid(DIRECTION_NONE, recycler);
        }
    }

    /*
     * Return value determines if a boundary has been reached
     * (for edge effects and flings). If returned value does not
     * match original delta (passed in), RecyclerView will draw
     * an edge effect.
     */
    return -delta;
}

我们获得了滚动距离(dx/dy)的增量来验证。方法的第一部分判断 按照所给的距离(标志给了滚动方向)滚动会不会超过边界。如果会, 我们需要计算出视图实际滚动的距离。

在这个方法里,我们需要自己手工移动这些视图。 offsetChildrenVertical()offsetChildrenHorizontal() 这两个方法 可以帮助我们处理匀速移动。 如果你不实现它,你的视图就不会滚动。 移动视图操作完成后,我们触发另一个填充操作, 根据滚动的距离替换视图。

最后,将实际位移距离应用给子视图。RecyclerView 根据这个值判断是否 绘制到达边界的效果。一般意义上,如果返回值不等于传入的值就意味着 需要绘制边缘的发光效果了。 如果你返回了一个带有错误方向的值,框架的函数会把这个当做一个大的变化 你将不能获得正确的边缘发光特效。

除了用来判断绘制边界特效外,返回值还被用来决定什么时候取消 flings。 返回错误的值会让你失去对 content fling 的控制。框架会认为你已经提前 触碰到边缘并取消了 fling。

热身结束~

目前,我们已经实现了基本的功能。它少了很多的细节部分, 不过滚动和适当的视图回收已经完成了。 关于自定义 LayoutManager 还有很多要说的东西。 接下来,我们会细致的介绍 decorations, data set changes 还有实现滚动到特定位置。


 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
简化Android的UI开发 5年以前  |  521177次阅读
Android 深色模式适配原理分析 4年以前  |  29536次阅读
Android阴影实现的几种方案 2年以前  |  12057次阅读
Android 样式系统 | 主题背景覆盖 4年以前  |  10211次阅读
 目录