使用Android API之RecyclerView分页图像轮播

最近,我们正在研究一个新的屏幕,这个屏幕需要轮播显示图像。我们的UI / UX团队提出了以下设计方案:

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

实现的过程非常复杂,需要进行一些细微的调整才能达到真正平滑的用户体验。 在此篇文章中,我们将带大家逐步实现轮播,我将对​​自己做出的部分选择进行解释 ,例如:使用ViewPager,ViewPager2或RecyclerView?

在开始之前,我们仅需要设定一些目标,其中大部分是从上面的动画中推断出来的:

目标

1.轮播是分页的:用户逐页滚动浏览整个页面。每页有固定的位置。

2.当一个图像在焦点上时,图像居中,相邻部分在侧面部分可见。

3.可以混合轮播不同宽高比的图像(长的、宽的和/或方形图像)

4.中心图像的相邻部分距离中心越远,它们的比例就会逐渐缩小

5.会直接在已点击的图片上打开轮播

6.扩大目标:点击图片以滚动到它

7.扩大目标:当图像清晰对焦时,叠加层就会出现

配套app

我在本文中制作了一个示例app,逐步展示了解决方案的演变:https : //github.com/dadouf/PagingImageGallery。在本文中,你会看到…

:floppy_disk:配套app检查点: v0

你可以通过检查程序包来引用GitHub上v0的代码,也可以通过在app的下拉列表中选择v0进行测试。 所有的实现都是 并排 存在的 可以轻松对它们进行比较。

基于ViewPager的方法:死胡同

看到模拟动画后,我直接选择了ViewPager。每次我不得不实现一个分页组件时,ViewPager都可以完成工作-这就是它的目的!不过这次不是…

我可能会在另一篇文章中用ViewPager(和ViewPager2)写我的试验和遇到的困难,但简而言之:对于基础版本它可以正常工作,当你使用诸如宽高比和缩放等更高级的内容时,它就会变得非常乏味。

而RecyclerView更灵活,逐步调整之后,我设法用RecyclerView获取了一个完美的解决方案。系好安全带,我们要出发啦!

基于RecyclerView的方法:white_check_mark:

v0:设置基准

让我们使用水平LinearLayoutManager设置一个RecyclerView。每个项目都是一个简单的ImageView,其width = match_parent 、height = match_parent 。我们通过ItemDecoration在所有项目里都使用了16dp间距。配套app包含一组示例图像URL,我们只需使用Glide即可加载它们。

以下是我们的起始点:
%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
在中间很突兀

:floppy_disk:配套 app检查点:v0

v1:添加宽高比处理

尊重每张图片的长宽比的目的是,我们不需要项目之间多余的空格。现在,每个项目视图都占据RecyclerView的整个宽度,这意味着一个较大图像的左右两侧都有很多额外的空白。我们希望图像彼此相邻,仅以我们在v0中定义的16dp间距分隔开。

如果我们将每个项目视图的宽度更改为 wrap_content ,那么可以非常快地朝预期的效果迈出一步:尊重宽高比。然而有两个问题:

1.滚动真的很麻烦。我们靠Glide来调整宽度:它或多或少是做到了,但仅在图像加载后才能进行。

2.尽管考虑了宽高比,但图像的高度 match_parent 和宽度超出了屏幕宽度,因此不能完全看到宽的图像。

:floppy_disk:配套app检查点: v1-alpha1

尽管这个方法不太合适,但修改宽度确实是正确的方法。我们需要做的不是简单的 wrap_content ,而是 预先确定 onBindViewHolder 中每个项目视图的宽度 。为此,我们需要先知道两个要素。首先,我们必须在加载图像之前知道图像的长宽比。

data class Image(val url: String, val width: Int, val height: Int) {
    val aspectRatio: Float
        get() = width.toFloat() / height.toFloat()
}

然后,在开始绑定项目之前,我们需要了解RecyclerView的宽度和高度(以像素为单位)。一个好方法是 onCreateViewHolder, 因为当该方法被调用时已经对RecyclerView进行了测量:

private var hasInitParentDimensions = false
private var maxImageWidth: Int = 0
private var maxImageHeight: Int = 0
private var maxImageAspectRatio: Float = 1f

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
    if (!hasInitParentDimensions) {
        maxImageWidth = parent.width
        maxImageHeight = parent.height
        maxImageAspectRatio = maxImageWidth.toFloat() / maxImageHeight.toFloat()
        hasInitParentDimensions = true
    }

    return VH(ImageView(parent.context))
}

现在,我们可以将RecyclerView的宽高比与每个图像的宽高比进行比较,并在附加ViewHolder和加载图像之前相应地调整大小:

image

滚动很流畅,所有图像均以正确的宽高比完整显示。耶!

:floppy_disk: Companion app checkpoint: v1-alpha2

:floppy_disk:配套app检查点: v1-alpha2

在总结宽高比之前,让我们加快脚步。目前,一张宽的图像占据了RecyclerView的整个宽度,但是我们希望能够看到其左右相邻的一部分。让人开心的是,只需进行简单的调整即可: 将图片宽度限制为最大总宽度的75%

maxImageWidth = (parent.width * 0.75f).roundToInt()

以下是我们得到的:

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC1
滑动

*:floppy_disk:配套app检查点:v1-final*

It’s starting to look like something! But we’re not completely there yet. Let’s implement paging.

它开始有点那味了!但是我们还没完成。接下来让我们开始实现分页。

v2:添加分页

我们的轮播自由滚动,但是我们希望它可以在滚动时或滚动后捕捉到每个单独的图像。

值得庆幸的是,RecyclerView随附了一些帮助程序来调节滚动和甩动的辅助工具( SnapHelpers )。尤其是,有一个叫 PagerSnapHelper 辅助工具的功能恰好满足了我们的要求:它使Recycler-View具有类似ViewPager的行为,确保你滚动到或使其中的每个项目都位于中心。设置非常简单:

PagerSnapHelper().attachToRecyclerView(recyclerView)

:floppy_disk:配套app检查点:v2-alpha1

对于第一项和最后一项,我们仍然有一个小问题:默认情况下它们不会居中,因为它们固定在RecyclerView的左端或右端。为了解决这个问题,我们只需通过 ItemDecoration 在这些项目的左侧或右侧添加额外的空格的来抵消它们。注意,此方法仅影响第一个和最后一个项目:

class BoundsOffsetDecoration : ItemDecoration() {
    override fun getItemOffsets(outRect: Rect,
                                view: View,
                                parent: RecyclerView,
                                state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)

        val itemPosition = parent.getChildAdapterPosition(view)

        // It is crucial to refer to layoutParams.width
        // (view.width is 0 at this time)!
        val itemWidth = view.layoutParams.width
        val offset = (parent.width - itemWidth) / 2

        if (itemPosition == 0) {
            outRect.left = offset
        } else if (itemPosition == state.itemCount - 1) {
            outRect.right = offset
        }
    }
}

现在,让我们谈一谈UX。在我们的app中,图像首先显示在网格中。单击网格中的任何这些图像可将轮播显示为“详细视图”。目前,轮播始终在位置0处打开,但我们希望它 在被点击的图片打开 。听起来很容易吧?但这需要一些技巧。

如果你只是调用 layoutManager.scrollToPosition (比如在onCreate中),则轮播会在目标位置附近立即加载,但会有一些偏离。发生的情况是它滚动到正确的项目,但没有考虑PagerSnapHelper的效果,因此目标项目被固定在RecyclerView的左侧。

配套app检查点: v2-alpha2

为了解决这个问题,我们 在RecyclerView及其项目布置好后立即手动调整滚动位置 。我们不必执行任何复杂的偏移量计算:SnapHelper会为我们处理它。

private fun initRecyclerViewPosition(position: Int) {
    // This initial scroll will be slightly off because it doesn't
    // respect the SnapHelper. Do it anyway so that the target view
    // is laid out, then adjust onPreDraw.

    layoutManager.scrollToPosition(position)

    recyclerView.doOnPreDraw {
        val targetView = layoutManager.findViewByPosition(position)
                ?: return@doOnPreDraw

        val distanceToFinalSnap = snapHelper.calculateDistanceToFinalSnap(layoutManager, targetView)
                ?: return@doOnPreDraw

        layoutManager.scrollToPositionWithOffset(position, -distanceToFinalSnap[0])
    }
}

将所有分页元素放在一起后,我们将得到:
%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC1
COOL! :sunglasses: :skateboard:

:floppy_disk:配套app检查点: v2-final

注意:你可以通过将padding left / right和 clipToPadding=false 组合使用来达到类似的“居中”效果 。你会在StackOverflow上找到使用此技术的几种解决方案,我最初也尝试过这种方法。但不得不说它使整个过程变得更加复杂:你需要找到正确的(足够大的)填充值,在尝试捕捉到上一个视图时它会被覆盖,会使初始滚动计算变得复杂。最后,我发现简单的 BoundsOffsetDecoration 方法 避免了这些问题,所以现在我完全不用RecyclerView的填充或者 clipToPadding 了。

v3:添加酷炫的收缩/扩大动画

至此,我们已经拥有了完备的图像轮播功能。我们在此基础上添加的所有内容都是对视觉和/或可用性的改进。

让我们添加在模拟中看到的效果:收缩/增长动画,有点让人联想到后期的iTunes Cover Flow(RIP)。我们希望图像从中心到远处逐渐缩小。

从字面上看,修改每个项目的属性(scale,transitionX等)的最佳位置就是负责布局它们的类:LayoutManager。因此,让我们创建一个称为LinearLayoutManager的子类:我们命名为 ProminentLayoutManager ,它能使中心项比其相邻项更加突出。在其中,我们将 循环访问所有子项,并在计算出他们到中心的距离后调整他们的比例

image

由于每次RecyclerView水平滚动某些像素时都会调用scaleChildren,因此缩放看起来是渐进且平滑的。

参数 minScaleDistanceFactorscaleDownBy 受缩小项目的数量以及达到最小尺寸的影响,可 随时使用他们一起调整动画。

所以现在项目正在按比例缩小并且变得非常顺滑!

:floppy_disk:配套app检查点 v3-alpha1

但是,像我们这样进行缩放的一个不良影响是,它会创建额外的水平空白:视图会缩放,但其边界不会缩放。如前所述,我们希望每个项目之间保持16dp的间隔,并且无论比例大小,该间隔都应保持。

我们可以通过修改项目 translationX ,以相同的方法轻松对其进行修复。我们 抵消了缩放所创建的空白,以使每个邻居都“锚定”到中心视图

val translationDirection = if (childCenter > containerCenter) -1 else 1
val translationXFromScale = translationDirection * child.width * (1 - scale) / 2f
child.translationX = translationXFromScale

:floppy_disk:配套app检查点: v3-alpha2

:bulb: 提示:一旦在开发人员设置中启用“显示布局范围”,所有缩放和转换的效果就变得更加容易理解。

这看起来更好。但是,让我们来进一步思考相同的问题。当中心项位于位置N时,它的直接邻居(在N-1和N + 1处)时正确锚定,且没有多余的空格。但是,当你开始向右拖动时,两个左侧项目之间的空间就会超过16dp(反之亦然)。如果你的手机在横向且正在观看布局较大的图像,这一点更加明显:

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC1
碰碰车

这是由比例尺创建的额外水平空间:这次是在两个比例缩小的项目之间。同样,我们要做的只是通过一些花式计算来抵消 translationX 。像以前一样,有关的花式计算是在循环访问子类进行的。当我们看N处的子类时:

  1. 我们计算由N缩放产生的额外空白
  2. 我们编辑N-1以将该空格添加到它的 translationX
  3. 我们将其传递给N + 1,以将空格添加到它的 translationX

image

:floppy_disk:配套app检查点: v3-alpha3

如果你使用“显示布局范围”,会注意到,为了保持均匀的间距,会导致图像超出其范围绘制。尽管这确实是我们想要的,但它意味着某些视图之外的图像也在图中,因为 translationX 将它们“拖”回了中心。当某个视图被认为不可见时,RecyclerView可能会决定不附加该视图或对其进行回收。你可能会注意到边缘上的视图突然出现或消失:这就是原因。

要解决此问题,我们需要让 RecyclerView尽早加载视图,同时保留更长的时间,以使图像不会在视图中弹进和弹出。 我们需要在两个地方做出设置:

  1. 在onCreate中,设置 recyclerView.setItemViewCacheSize(4) ,这样在回收视图之前就能保留更长的时间
  2. 在我们的自定义ProminentLayoutManager中,有一种方法可以重写以将多余的项目布置在视线之外, getExtraLayoutSpace
override fun getExtraLayoutSpace(state: RecyclerView.State): Int {
    // The more we scale down, the more extra space we need
    return (width / (1 - scaleDownBy)).roundToInt()
}

确定这些值多少有些武断:我只是简单地测试了几个,然后选择 了有效的方法

如果你现在进行测试,不应该看到边缘突然出现或消失的视图。
%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC1
Next. Next. Next. Skip tutorial.

配套app检查点:v3- 最终

v4:点按图片即可滚动

我们在这里开始涉足伸展目标,我想解决 smoothScrollToPosition ,这是一个很常见的用例。在我们的app中, 我们只想点击即可滚动到下一张图像 。这样,我们不仅允许用户可以进行视图滚动操作,还可以点击进行导航。

一个基本操作在onBindViewHolder中仅占用三行代码:

vh.imageView.setOnClickListener {
    val rv = (vh.imageView.parent) as RecyclerView
    rv.smoothScrollToPosition(position)
}

在大多数情况下都可以使用。但是,在较大的图像上尝试时(将手机置于横向),你会发现一个问题:RecyclerView开始滚动到被点击的视图,但随后会将另一个视图固定到位。实际上,它所做的只是稍微滚动一下,然后找到最靠近中心的视图捕捉视图。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC3
Resistance

:floppy_disk:配套app检查点: v4-alpha1

因此,我们需要帮助RecyclerView。当你调用Android API ReyclerView.smoothScrollToPosition 时,它在内部执行的操作将委托给LayoutManager,后者最终创建并使用 LinearSmoothScroller 进行滚动。默认情况下,LinearSmoothScroller将滚动到项目可见的程度,因此该项目最终在RecyclerView的开始处(如果超出开始范围)或在RecyclerView的结束处(如果已结束的边界)。

由于有两个常数,默认行为很容易被覆盖: SNAP_TO_STARTSNAP_TO_END 。但是我们想要的是固定 在中心位置 ,而不是固定在开头或结尾的位置,但不幸的是,没有 SNAP_TO_CENTER 常数。

尽管很简单,我们却可以获得所需的行为。如果 dS 是到视图起点 dE 是到视图终点的距离,则到视图中心的距离就是简单的 (dS+dE) / 2 (一半)。好消息是,计​​算滚动距离(dx)正是 LinearSmoothScroller.calculateDxToMakeVisible 的工作,我们可以对它进行覆盖。

我们将这汇总为一个不错的扩展方法,就是点击监听器进行回调:
image
只需轻轻点击,就算是较大的图像也会被捕捉到正确的位置。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC3
Resistance结尾处

配套app检查点: v4-final

v5:检测视图何时突出显示

现在,我们要检测图像在什么时候开始突出显示。在我们的app中,当图片变得突出时,我们将显示一个分享按钮叠加,当它不再突出时,我们将隐藏它。

首先,我们将突出显示定义为 靠近中心 ,例如 在中心72dp以内 。我们的 ProminentLayoutManager 是确定此位置的好地方,因为它已经在检查距离来确定比例。

image

然后,我们将每个项目从简单的 ImageView 更改为仅有一个ImageView +一个ImageButton的自定义项 OverlayableImageView我们将使用现有的Android view属性 isActivated 将视图标记为突出显示。

ProminentLayoutManager现在只需要设置相应的 isActivated

就是这样!没毛病!
%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC4
8 到 0英里

配套app检查点: v5-final

注意:还有其他方法可以检测突出视图。 也许最自然的方法是使用滚动侦听器,并在RecyclerView停止滚动后调用 SnapHelper.findSnapView 。 我们的方法的主要区别在于用户体验的不同:叠 加层将在以后显示,因为视图需要一些时间才能固定到位。总之,这完全取决于你app对突出显示的定义。

最终版本

最后,我们只需添加一些视觉触感(边缘和圆角上的渐变)和“共享”按钮的操作即可。 做得好!

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC6

:floppy_disk: 配套app检查点 vfinal

结论

我试图列出“吸到的教训”,但老实说,我总是以粗体显示在顶部。 如果有一项原则可以保留,那就是:

不要害怕修改像素距离,以及translateX和其他看似“低级”的视图属性。 首先,这是理解和调整滚动和动画工作方式的关键。 其次,这正是Android框架在幕后所做的。

早些时候,我以iTunes Cover Cover Flow的设计为灵感,但是实际的Cover Flow效果比我们实施的效果更像“ 3D效果”:它不仅可以缩小比例,还可以旋转并堆叠视图。 也就是说,如果你采用相同的思维方式并开始使用缩放和旋转属性,则很有可能会获得类似的效果。

玩得开心!

以下位置可找到完整代码: https://github.com/dadouf/PagingImageGallery

原文作者 David Ferrand
原文链接 https://proandroiddev.com/paging-image-gallery-with-recyclerview-f059d035b7e7

推荐阅读
相关专栏
开发者实践
186 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。