最近,我们正在研究一个新的屏幕,这个屏幕需要轮播显示图像。我们的UI / UX团队提出了以下设计方案:
实现的过程非常复杂,需要进行一些细微的调整才能达到真正平滑的用户体验。 在此篇文章中,我们将带大家逐步实现轮播,我将对自己做出的部分选择进行解释 ,例如:使用ViewPager,ViewPager2或RecyclerView?
在开始之前,我们仅需要设定一些目标,其中大部分是从上面的动画中推断出来的:
目标
1.轮播是分页的:用户逐页滚动浏览整个页面。每页有固定的位置。
2.当一个图像在焦点上时,图像居中,相邻部分在侧面部分可见。
3.可以混合轮播不同宽高比的图像(长的、宽的和/或方形图像)
4.中心图像的相邻部分距离中心越远,它们的比例就会逐渐缩小
5.会直接在已点击的图片上打开轮播
6.扩大目标:点击图片以滚动到它
7.扩大目标:当图像清晰对焦时,叠加层就会出现
配套app
我在本文中制作了一个示例app,逐步展示了解决方案的演变:https : //github.com/dadouf/PagingImageGallery。在本文中,你会看到…
配套app检查点: v0
你可以通过检查程序包来引用GitHub上v0的代码,也可以通过在app的下拉列表中选择v0进行测试。 所有的实现都是 并排 存在的 , 可以轻松对它们进行比较。
基于ViewPager的方法:死胡同
看到模拟动画后,我直接选择了ViewPager。每次我不得不实现一个分页组件时,ViewPager都可以完成工作-这就是它的目的!不过这次不是…
我可能会在另一篇文章中用ViewPager(和ViewPager2)写我的试验和遇到的困难,但简而言之:对于基础版本它可以正常工作,当你使用诸如宽高比和缩放等更高级的内容时,它就会变得非常乏味。
而RecyclerView更灵活,逐步调整之后,我设法用RecyclerView获取了一个完美的解决方案。系好安全带,我们要出发啦!
基于RecyclerView的方法
v0:设置基准
让我们使用水平LinearLayoutManager设置一个RecyclerView。每个项目都是一个简单的ImageView,其width = match_parent
、height = match_parent
。我们通过ItemDecoration在所有项目里都使用了16dp间距。配套app包含一组示例图像URL,我们只需使用Glide即可加载它们。
以下是我们的起始点:
在中间很突兀
配套 app检查点:v0
v1:添加宽高比处理
尊重每张图片的长宽比的目的是,我们不需要项目之间多余的空格。现在,每个项目视图都占据RecyclerView的整个宽度,这意味着一个较大图像的左右两侧都有很多额外的空白。我们希望图像彼此相邻,仅以我们在v0中定义的16dp间距分隔开。
如果我们将每个项目视图的宽度更改为 wrap_content
,那么可以非常快地朝预期的效果迈出一步:尊重宽高比。然而有两个问题:
1.滚动真的很麻烦。我们靠Glide来调整宽度:它或多或少是做到了,但仅在图像加载后才能进行。
2.尽管考虑了宽高比,但图像的高度 match_parent
和宽度超出了屏幕宽度,因此不能完全看到宽的图像。
配套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和加载图像之前相应地调整大小:
滚动很流畅,所有图像均以正确的宽高比完整显示。耶!
Companion app checkpoint: v1-alpha2
配套app检查点: v1-alpha2
在总结宽高比之前,让我们加快脚步。目前,一张宽的图像占据了RecyclerView的整个宽度,但是我们希望能够看到其左右相邻的一部分。让人开心的是,只需进行简单的调整即可: 将图片宽度限制为最大总宽度的75% :
maxImageWidth = (parent.width * 0.75f).roundToInt()
以下是我们得到的:
滑动
*配套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)
配套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])
}
}
将所有分页元素放在一起后,我们将得到:
COOL!
配套app检查点: v2-final
注意:你可以通过将padding left / right和 clipToPadding=false
组合使用来达到类似的“居中”效果 。你会在StackOverflow上找到使用此技术的几种解决方案,我最初也尝试过这种方法。但不得不说它使整个过程变得更加复杂:你需要找到正确的(足够大的)填充值,在尝试捕捉到上一个视图时它会被覆盖,会使初始滚动计算变得复杂。最后,我发现简单的 BoundsOffsetDecoration
方法
避免了这些问题,所以现在我完全不用RecyclerView的填充或者 clipToPadding
了。
v3:添加酷炫的收缩/扩大动画
至此,我们已经拥有了完备的图像轮播功能。我们在此基础上添加的所有内容都是对视觉和/或可用性的改进。
让我们添加在模拟中看到的效果:收缩/增长动画,有点让人联想到后期的iTunes Cover Flow(RIP)。我们希望图像从中心到远处逐渐缩小。
从字面上看,修改每个项目的属性(scale,transitionX等)的最佳位置就是负责布局它们的类:LayoutManager。因此,让我们创建一个称为LinearLayoutManager的子类:我们命名为 ProminentLayoutManager
,它能使中心项比其相邻项更加突出。在其中,我们将 循环访问所有子项,并在计算出他们到中心的距离后调整他们的比例 。
由于每次RecyclerView水平滚动某些像素时都会调用scaleChildren,因此缩放看起来是渐进且平滑的。
参数 minScaleDistanceFactor
和 scaleDownBy
受缩小项目的数量以及达到最小尺寸的影响,可 随时使用他们一起调整动画。
所以现在项目正在按比例缩小并且变得非常顺滑!
配套app检查点 : v3-alpha1
但是,像我们这样进行缩放的一个不良影响是,它会创建额外的水平空白:视图会缩放,但其边界不会缩放。如前所述,我们希望每个项目之间保持16dp的间隔,并且无论比例大小,该间隔都应保持。
我们可以通过修改项目 translationX
,以相同的方法轻松对其进行修复。我们 抵消了缩放所创建的空白,以使每个邻居都“锚定”到中心视图 :
val translationDirection = if (childCenter > containerCenter) -1 else 1
val translationXFromScale = translationDirection * child.width * (1 - scale) / 2f
child.translationX = translationXFromScale
配套app检查点: v3-alpha2
提示:一旦在开发人员设置中启用“显示布局范围”,所有缩放和转换的效果就变得更加容易理解。
这看起来更好。但是,让我们来进一步思考相同的问题。当中心项位于位置N时,它的直接邻居(在N-1和N + 1处)时正确锚定,且没有多余的空格。但是,当你开始向右拖动时,两个左侧项目之间的空间就会超过16dp(反之亦然)。如果你的手机在横向且正在观看布局较大的图像,这一点更加明显:
碰碰车
这是由比例尺创建的额外水平空间:这次是在两个比例缩小的项目之间。同样,我们要做的只是通过一些花式计算来抵消 translationX
。像以前一样,有关的花式计算是在循环访问子类进行的。当我们看N处的子类时:
- 我们计算由N缩放产生的额外空白
- 我们编辑N-1以将该空格添加到它的
translationX
- 我们将其传递给N + 1,以将空格添加到它的
translationX
配套app检查点: v3-alpha3
如果你使用“显示布局范围”,会注意到,为了保持均匀的间距,会导致图像超出其范围绘制。尽管这确实是我们想要的,但它意味着某些视图之外的图像也在图中,因为 translationX
将它们“拖”回了中心。当某个视图被认为不可见时,RecyclerView可能会决定不附加该视图或对其进行回收。你可能会注意到边缘上的视图突然出现或消失:这就是原因。
要解决此问题,我们需要让 RecyclerView尽早加载视图,同时保留更长的时间,以使图像不会在视图中弹进和弹出。 我们需要在两个地方做出设置:
- 在onCreate中,设置
recyclerView.setItemViewCacheSize(4)
,这样在回收视图之前就能保留更长的时间 - 在我们的自定义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()
}
确定这些值多少有些武断:我只是简单地测试了几个,然后选择 了有效的方法 。
如果你现在进行测试,不应该看到边缘突然出现或消失的视图。
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开始滚动到被点击的视图,但随后会将另一个视图固定到位。实际上,它所做的只是稍微滚动一下,然后找到最靠近中心的视图捕捉视图。
Resistance
配套app检查点: v4-alpha1
因此,我们需要帮助RecyclerView。当你调用Android API ReyclerView.smoothScrollToPosition
时,它在内部执行的操作将委托给LayoutManager,后者最终创建并使用 LinearSmoothScroller
进行滚动。默认情况下,LinearSmoothScroller将滚动到项目可见的程度,因此该项目最终在RecyclerView的开始处(如果超出开始范围)或在RecyclerView的结束处(如果已结束的边界)。
由于有两个常数,默认行为很容易被覆盖: SNAP_TO_START
或 SNAP_TO_END
。但是我们想要的是固定 在中心位置 ,而不是固定在开头或结尾的位置,但不幸的是,没有 SNAP_TO_CENTER
常数。
尽管很简单,我们却可以获得所需的行为。如果 dS
是到视图起点 dE
是到视图终点的距离,则到视图中心的距离就是简单的 (dS+dE) / 2
(一半)。好消息是,计算滚动距离(dx)正是 LinearSmoothScroller.calculateDxToMakeVisible
的工作,我们可以对它进行覆盖。
我们将这汇总为一个不错的扩展方法,就是点击监听器进行回调:
只需轻轻点击,就算是较大的图像也会被捕捉到正确的位置。
Resistance结尾处
配套app检查点: v4-final
v5:检测视图何时突出显示
现在,我们要检测图像在什么时候开始突出显示。在我们的app中,当图片变得突出时,我们将显示一个分享按钮叠加,当它不再突出时,我们将隐藏它。
首先,我们将突出显示定义为 靠近中心 ,例如 在中心72dp以内 。我们的 ProminentLayoutManager
是确定此位置的好地方,因为它已经在检查距离来确定比例。
然后,我们将每个项目从简单的 ImageView
更改为仅有一个ImageView +一个ImageButton的自定义项 OverlayableImageView
。 我们将使用现有的Android view属性 isActivated
将视图标记为突出显示。
ProminentLayoutManager现在只需要设置相应的 isActivated
。
就是这样!没毛病!
8 到 0英里
配套app检查点: v5-final
注意:还有其他方法可以检测突出视图。 也许最自然的方法是使用滚动侦听器,并在RecyclerView停止滚动后调用 SnapHelper.findSnapView
。 我们的方法的主要区别在于用户体验的不同:叠 加层将在以后显示,因为视图需要一些时间才能固定到位。总之,这完全取决于你app对突出显示的定义。
最终版本
最后,我们只需添加一些视觉触感(边缘和圆角上的渐变)和“共享”按钮的操作即可。 做得好!
配套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