评论

厉害了!RecyclerView 模拟三种翻页效果~

原标题:厉害了!RecyclerView 模拟三种翻页效果~

作者:三尺丶

juejin.cn/post/7244819106343829564

1. 整体逻辑

为何直接对 RecyclerView 进行扩展而不使用 ViewPager/ViewPager2 ?原因如下:

  1. Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附)

  2. Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以实现更好的控制

  3. RecyclerView方便拓展,同时三种模式同时使用RecyclerView实现,便于复用

实现逻辑:三种滑动模式都在 RecyclerView 地基础上更改其滑动行为,横向滑动需要修改子View层级,仿真翻页需要再覆盖一层仿真动画

2. 横向覆盖滑动(Slide Mode)

Slide Mode 最适合直接使用 ViewPager,不过我们还是以 RecyclerView 为基础来实现,让三种模式统一实现方式。实现思路:先实现跨页吸附,再实现覆盖翻页效果

2.1 跨页吸附

实现跨页吸附,需要在手指离开屏幕时对 RecyclerView 进行复位吸附操作,有两种情况:

2.1.1 Scroll Idle

拖拽发生后,RecyclerView 滑动状态变为 SCROLL_STATE_IDLE 时,需要进行复位吸附操作

// OrientationHelper为系统提供的辅助类,LayoutManager的包装类

// 可以让我们方便的计算出RecyclerView相关的各种宽高,计算结果和LayoutManager方向相关

openfunsnapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {

vallm = mRecyclerView.layoutManager ?: returnnull

valchildCount = lm.childCount // 可见数量

if(childCount < 1) returnnull

varclosestChild: View? = null

varabsClosest = Int.MAX_VALUE

varscrollDistance = 0

// RecyclerView中心点,LayoutManager为竖向则是Y轴坐标,为横向则是X轴坐标

valcontainerCenter = helper.startAfterPadding + helper.totalSpace / 2

// 从可见Item中找到距RecyclerView离中心最近的View

for(i in0until childCount) {

valchild = lm.getChildAt(i) ?: continue

if(consumeSnap(i, child)) returnnull// consumeSnap 默认返回false,竖直滑动模式才使用

valchildCenter = (helper.getDecoratedStart(child)

+ helper.getDecoratedMeasurement(child) / 2)

valabsDistance = abs(childCenter - containerCenter)

if(absDistance < absClosest) {

absClosest = absDistance

closestChild = child

scrollDistance = childCenter - containerCenter

}

}

closestChild ?: returnnull

// 滑动

when(orientation) {

VERTICAL -> mRecyclerView.smoothScrollBy(0, scrollDistance)

HORIZONTAL -> mRecyclerView.smoothScrollBy(scrollDistance, 0)

}

returnPair(scrollDistance, lm.getPosition(closestChild))

}

2.1.2 Fling

可以通过 RecyclerView 提供的 OnFlingListener 消费掉 Fling,将其转化为 SmoothScroll ,滑动到指定位置

1. 找到吸附目标的位置(adapter position)openfunfindTargetSnapPosition(

lm: RecyclerView.LayoutManager,

velocity: Int,

helper: OrientationHelper

): Int{

valitemCount: Int= lm.itemCount

if(itemCount == 0) returnRecyclerView.NO_POSITION

// 中心点以前距离最近的View

varclosestChildBeforeCenter: View? = null

vardistanceBefore = Int.MIN_VALUE // 中心点以前,距离为负数

// 中心点以后距离最近的View

varclosestChildAfterCenter: View? = null

vardistanceAfter = Int.MAX_VALUE // 中心点以后,距离为正数

valcontainerCenter = helper.startAfterPadding + helper.totalSpace / 2

valchildCount: Int= lm.childCount

for(i in0until childCount) {

valchild = lm.getChildAt(i) ?: continue

if(consumeSnap(i, child)) returnRecyclerView.NO_POSITION // consumeSnap 默认返回false,竖直滑动模式才使用

valchildCenter = (helper.getDecoratedStart(child)

+ helper.getDecoratedMeasurement(child) / 2)

valdistance = childCenter - containerCenter

// Fling需要考虑方向,先获取两个方向最近的View

if(distance in(distanceBefore + 1)..0) {

distanceBefore = distance

closestChildBeforeCenter = child

}

if(distance in0until distanceAfter) {

distanceAfter = distance

closestChildAfterCenter = child

}

}

// 根据方向选择Fling到哪个View

valforwardDirection = velocity > 0

if(forwardDirection && closestChildAfterCenter != null) {

returnlm.getPosition(closestChildAfterCenter)

} elseif(!forwardDirection && closestChildBeforeCenter != null) {

returnlm.getPosition(closestChildBeforeCenter)

}

// 边界情况处理

valvisibleView =

(if(forwardDirection) closestChildBeforeCenter elseclosestChildAfterCenter)

?: returnRecyclerView.NO_POSITION

valvisiblePosition: Int= lm.getPosition(visibleView)

valsnapToPosition = (visiblePosition - 1)

returnif(snapToPosition < 0|| snapToPosition >= itemCount) {

RecyclerView.NO_POSITION

} elsesnapToPosition

}

2. 使用 RecyclerView 的 LinearSmoothScroller 完成吸附动画privatefuncreateScroller(

oh: OrientationHelper

): LinearSmoothScroller {

returnobject: LinearSmoothScroller(mRecyclerView.context) {

overridefunonTargetFound(

targetView: View,

state: RecyclerView.State,

action: Action

){

vald = distanceToCenter(targetView, oh)

valtime = calculateTimeForDeceleration(abs(d))

if(time > 0) {

when(orientation) {

VERTICAL -> action.update(0, d, time, mDecelerateInterpolator)

HORIZONTAL -> action.update(d, 0, time, mDecelerateInterpolator)

}

}

}

overridefuncalculateSpeedPerPixel(displayMetrics: DisplayMetrics)=

100f/ displayMetrics.densityDpi

overridefuncalculateTimeForScrolling(dx: Int)=

100.coerceAtMost(super.calculateTimeForScrolling(dx))

}

}

protectedfundistanceToCenter(targetView: View, helper: OrientationHelper): Int{

valchildCenter = (helper.getDecoratedStart(targetView)

+ helper.getDecoratedMeasurement(targetView) / 2)

valcontainerCenter = helper.startAfterPadding + helper.totalSpace / 2

returnchildCenter - containerCenter

}

完整操作:

protectedfunsnapFromFling(

lm: RecyclerView.LayoutManager,

velocity: Int,

helper: OrientationHelper

): Pair<Boolean, Int> {

valtargetPosition = findTargetSnapPosition(lm, velocity, helper)

if(targetPosition == RecyclerView.NO_POSITION) returnPair(false, 0)

valsmoothScroller = createScroller(helper)

smoothScroller.targetPosition = targetPosition

lm.startSmoothScroll(smoothScroller)

returnPair(true, targetPosition) // 消费fling

}

2.2 覆盖效果实现2.2.1 如果使用PageTransform实现

如果使用ViewPager的PageTransform,是可以实现覆盖动画的,实现思路:使可见View的第二个View跟随屏幕滑动

假设上图蓝色透明矩形为屏幕,其他为ItemView,图片上半部分正常滑动的状态,下半部分为 translate view 之后的状态。可以看到,在横向滑动过程中,最多可见2个View(蓝色透明方框最多覆盖2个View),此时将第二个View跟随屏幕,其他View保持跟随画布滑动,即可达到效果。在OnPageScroll回调中实现这个逻辑:

for(i in0until layoutManager.childCount) {

layoutManager.getChildAt(i)?.also { view ->

if(i == 1) {

// view.left是个负数,offsetPx(=-view.left)是个正数

view.translationX = offsetPx.toFloat - view.width // 需要translate的距离(向前移需要负数)

} else{

// 恢复其余位置的translate

view.translationX = 0f

}

}

}

2.2.2 扩展 RecyclerView 实现覆盖翻页

知道如何通过 PageTransfrom 实现后,我们来看看直接使用 RecyclerView 如何实现。观看ViewPager2源码可知PageTransfrom的实现方式

故我们直接copy代码,在OnScrollListener中自行实现onPageScrolled回调即可实现覆盖翻页效果。

但是此时还有一个问题,就是子View的层级问题,你会发现上面的滑动示意图中,绿色View会在黄色View之上,如何解决这个问题呢?我们需要控制View的绘制顺序,前面的View后绘制,保证前面地View在后面的View的绘制层级之上。

观看源码会发现,RecyclerView其实提供了一个回调ChildDrawingOrderCallback,可以很方便地实现这个效果:

overridefunattach{

super.attach

mRecyclerView.setChildDrawingOrderCallback(this)

}

overridefunonGetChildDrawingOrder(childCount: Int, i: Int)= childCount - i - 1// 反向绘制

3. 竖直滑动(Scroll Mode)

竖直滑动需要滑动到跨章的位置时才吸附(自动回滚到指定位置),需要实现两个效果:跨章吸附、跨章Fling阻断。我们可以在横向覆盖滑动(Slide Mode)的基础上做一个减法,首先将 LayoutManager 改为竖向的,然后实现上述两个效果。

3.1 跨章吸附

实现跨章吸附,我们先在 RecyclerView 的 Adapter 中对每个View进行一个标记:

companionobject{

constvalTYPE_NONE = 100// 其他

constvalTYPE_FIRST_PAGE = 101// 首页

constvalTYPE_LAST_PAGE = 102// 末页

}

funbind{ // onBindViewHolder 时调用

itemView.tag = when{

textPage.isLastPage -> TYPE_LAST_PAGE

textPage.isFirstPage -> TYPE_FIRST_PAGE

else-> TYPE_NONE

}

......

}

其次我们实现横向覆盖滑动(Slide Mode)中的一段代码(做一个减法):

// 如果不是章节的最后一页,则消费Snap(不进行吸附操作)

overridefunconsumeSnap(index: Int, child: View)=

index == 0&& child.tag != ReadBookAdapter.TYPE_LAST_PAGE

这样就可以实现不是跨越章节的翻页不进行吸附,而跨越章节的滑动会自动吸附。

3.2 跨章 Fling 阻断

在滑动过程中,基于可见View只有两个的情况:

  • 如果向上滑动,判断第一个可见View是否「末页」,如果是,smoothScroll到第二个可见View

  • 如果向下滑动,判断第二个可见View是否「首页」,如果是,smoothScroll到第一个可见View

private varinFling = false // 正在fling,在OnFlingListener中设置为true

privatevarinBlocking = false// 阻断fling

overridevalmScrollListener = object: RecyclerView.OnScrollListener {

varmScrolled = false

overridefunonScrollStateChanged(recyclerView: RecyclerView, newState: Int){

when(newState) {

RecyclerView.SCROLL_STATE_DRAGGING -> {

inFling = false// 重置inFling

}

RecyclerView.SCROLL_STATE_IDLE -> {

inFling = false// 重置inFling

if(inBlocking) {

inBlocking = false// 忽略阻断造成的IDLE

} elseif(mScrolled) {

mScrolled = false

snapToTargetExistingView(orientationHelper.value)

}

}

}

}

overridefunonScrolled(recyclerView: RecyclerView, dx: Int, dy: Int){

if(dy != 0) {

if(!mScrolled) {

this@VSnapHelper.mCallback.onScrollBegin

mScrolled = true

}

vallm = mRecyclerView.layoutManager ?: return

// fling阻断

if(inFling && !inBlocking) {

valchild: View?

valtype: Int

if(dy > 0) { // 向上滑动

child = lm.getChildAt(0)

type = ReadBookAdapter.TYPE_LAST_PAGE

} else{

child = lm.getChildAt(lm.childCount - 1)

type = ReadBookAdapter.TYPE_FIRST_PAGE

}

child?.let {

if(it.tag == type) {

inBlocking = true

vald = distanceToCenter(it, orientationHelper.value)

mRecyclerView.smoothScrollBy(0, d)

}

}

}

}

}

}

4. 仿真页(Flip Mode)

仿真页在横向覆盖滑动(Slide Mode)基础之上实现,我们还需要实现:

  1. 确认手指滑动方向

  2. 所有可见View都跟随屏幕

  3. 绘制次序根据拖拽方向改变,保证目标页在当前页之上

  4. 绘制仿真页

  5. 手指抬起后的翻页动画(确认Fling、Scroll Idle产生的两种Snap的方向,因为手指会来回滑动导致方向判断错误)

4.1 确认手指滑动方向

滑动方向不能直接在 onTouch、dispatchTouchEvent 这些方法中直接判断, 因为极微小的滑动都会决定方向,这样会造成轻微触碰就判定了方向,导致页面内容闪动、抖动等问题。

我们需要在滑动了一定距离后确定方向,最好的选择就是在 onPageScroll 中进行判断,系统为我们保证了 ScrollState 已变为 DRAGGING,此时用户100%已经在滑动。可以看下源码真正触发 onPageScroll 的条件有哪些

我们实现的判断方向的代码:

// 在onScrolled中调用

// mCurrentItem:onPageSelected中赋值,代表当前Item

// position:第一个可见View的位置

// offsetPx:第一个可见View的left取负

// mForward:方向,true为画布向左滑动(向尾部滑动),false画布向右滑动(向头部滑动)

privatefundispatchScrolled(position: Int, offsetPx: Int){

if(mScrollState == RecyclerView.SCROLL_STATE_DRAGGING) {

mForward = mCurrentItem == position

}

mCallback.onPageScrolled(position, mCurrentItem, offsetPx, mForward)

}

不过这个规则在超快速滑动时会判断错误,即settling直接变dragging的时候,所以会对滑动做一点限制

overridefundispatchTouchEvent(e: MotionEvent): Boolean{

if(snapHelper.mScrollState == RecyclerView.SCROLL_STATE_SETTLING) {

returntrue// sellting过程中禁止滑动

}

delegate.onTouch(e)

returnsuper.dispatchTouchEvent(e)

}

4.2 遮盖效果

所有可见View都跟随屏幕,横向覆盖滑动(Slide Mode)的增强版,因为给 RecyclerView 设置了 offScreenLimit=1 的效果,所以 LayoutManager 的 child 数量最多会有4个(参照 ViewPager2#LinearLayoutManagerImpl 实现,这里设置是为了滑动时可以第一时间生成目标页的截图)

// onPageScrolled中调用

privatefuntransform(offsetPx: Int, firstVisible: Int){

valcount = layoutManager.childCount

if(count == 2|| (count == 3&& offsetPx == 0)) {

// 可见View只有一个的时候,全部复位

for(i in0until count) {

layoutManager.getChildAt(i)?.also { view ->

view.translationX = 0f

}

}

} else{

vartarget = 1

if(count == 3&& firstVisible == 0) target-- // 首位适配,currentItem=0且存在滑动的时候

for(i in0until layoutManager.childCount) {

layoutManager.getChildAt(i)?.also { view ->

when(i) {

target -> view.translationX = offsetPx.toFloat

target + 1-> view.translationX = offsetPx.toFloat - view.width

else-> view.translationX = 0f

}

}

}

}

}

4.3 绘制次序根据拖拽方向改变

保证目标页在当前页之上,防止绘制的仿真页消失时出现闪屏(瞬间显示了不正确的页)

// 画布左移则反向绘制,右移则正想绘制

overridefungetDrawingOrder(childCount: Int, i: Int)=

if(snapHelper.mForward) childCount - i - 1elsei

4.4 绘制仿真页

我们在 RecyclerView 的父View上直接覆盖绘制一层仿真页Bitmap

4.4.1 生成截图

如上面所说,实现了 offScreenLimit=1 的效果,我们在首次获取到方向时生成截图:

// 生成截图方法

funView.screenshot: Bitmap? {

returnrunCatching {

valscreenshot = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)

valc = Canvas(screenshot)

c.translate(-scrollX.toFloat, -scrollY.toFloat)

draw(c)

screenshot

}.getOrNull

}

privatevarisBeginDrag = false

overridefunonPageStateChange(state: Int){

when(state) {

RecyclerView.SCROLL_STATE_DRAGGING -> {

isBeginDrag = true

}

}

}

overridefunonPageScrolled(firstVisible: Int, current: Int, offsetPx: Int, forward: Boolean){

if(isBeginDrag) {

isBeginDrag = false

delegate.apply {

if(forward) {

nextBitmap?.recycle

nextBitmap = layoutManager.findViewByPosition(current + 1)?.screenshot

curBitmap?.recycle

curBitmap = layoutManager.findViewByPosition(current)?.screenshot

} else{

prevBitmap?.recycle

prevBitmap = layoutManager.findViewByPosition(current - 1)?.screenshot

curBitmap?.recycle

curBitmap = layoutManager.findViewByPosition(current)?.screenshot

}

setDirection(if(forward) AnimDirection.NEXT elseAnimDirection.PREV)

}

invalidate

}

}

4.2 绘制仿真页

绘制仿真页参考 github.com/gedoor/legado 的 SimulationPageDelegate

  • 基础知识:三角函数、Android的矩阵、贝塞尔曲线、canvas.clipPath的 XOR & INTERSECT 模式

  • 绘制方法:参考仿真翻页 cnblogs.com/zsw-1993/p/4880502.html

  • 计算方法:使用手指触摸点和触摸点对应的角位置(比如触摸点靠近右下角,角位置就是右下角),这两个点可以算出所有参数

确认方向后,我们只用通过修改手指触碰点的参数即可控制整个动画(根据点击位置实时计算即可)

4.5 动画控制

手指抬起后的翻页动画通过 Scroller+invalidate 实现

overridefuncomputeScroll{

if(scroller.computeScrollOffset) {

setTouchPoint(scroller.currX.toFloat, scroller.currY.toFloat)

} elseif(isStarted) {

stopScroll

}

}

对于 Fling 和 Scroll Idle 产生的吸附效果,我们需要各自回调方向:

// 选中时开始动画,此时position改变

overridefunonPageSelected(position: Int){

valpage = adapter.data[position]

ReadBook.onPageChange(page)

if(canDraw) {

delegate.onAnimStart(300, false)

}

}

// position未改变的情况

overridefunonSnap(isFling: Boolean, forward: Boolean, changePosition: Boolean){

if(!changePosition) {

delegate.onAnimStart(

300,

true,

// 未改变方向,向前则播放向后动画

if(forward) AnimDirection.PREV elseAnimDirection.NEXT

)

}

}

Scroll Idle 通过 SmoothScroll 所需要滑动的距离正负判断方向:

// Scroll

overridefunsnapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {

mSnapping = true

super.snapToTargetExistingView(helper)?.also {

// first为滑动距离,second为目标Item的position

mCallback.onSnap(false, it.first > 0, mCurrentItem != it.second)

returnit

}

returnnull

}

// Fling

overridevalmFlingListener = object: RecyclerView.OnFlingListener {

overridefunonFling(velocityX: Int, velocityY: Int): Boolean{

vallm = mRecyclerView.layoutManager ?: returnfalse

mRecyclerView.adapter ?: returnfalse

valminFlingVelocity = mRecyclerView.minFlingVelocity

valresult = snapFromFling(

lm,

velocityX,

orientationHelper.value

)

valconsume = abs(velocityX) > minFlingVelocity && result.first

if(consume) {

mSnapping = true

// second为目标Item的position,这里直接通过速度正负来判断方向

mCallback.onSnap(true, velocityX > 0, result.second != mCurrentItem)

}

returnconsume

}

}

以上为所有关键点,只截取了部分代码,提供一个思路。

为了防止失联,欢迎关注我防备的小号

微信改了推送机制,真爱请星标本公号👇返回搜狐,查看更多

责任编辑:

平台声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。
阅读 ()
大家都在看
推荐阅读