使用RecyclerView实现三种阅读器翻页样式

一、整体逻辑

image.png

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

  1. Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附)
  2. Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以实现更好的控制
  3. RecyclerView方便拓展,同时三种模式同时使用RecyclerView实现,便于复用

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

二、横向覆盖滑动(Slide Mode)

横向.gif

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

1、跨页吸附

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

(1)Scroll Idle

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

// OrientationHelper为系统提供的辅助类,LayoutManager的包装类
// 可以让我们方便的计算出RecyclerView相关的各种宽高,计算结果和LayoutManager方向相关
open fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
    val lm = mRecyclerView.layoutManager ?: return null
    val childCount = lm.childCount // 可见数量
    if (childCount < 1) return null



    var closestChild: View? = null
    var absClosest = Int.MAX_VALUE
    var scrollDistance = 0
    // RecyclerView中心点,LayoutManager为竖向则是Y轴坐标,为横向则是X轴坐标
    val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

    // 从可见Item中找到距RecyclerView离中心最近的View
    for (i in 0 until childCount) {
        val child = lm.getChildAt(i) ?: continue
        if (consumeSnap(i, child)) return null // consumeSnap 默认返回false,竖直滑动模式才使用
        val childCenter = (helper.getDecoratedStart(child)
                + helper.getDecoratedMeasurement(child) / 2)
        val absDistance = abs(childCenter - containerCenter)
        if (absDistance < absClosest) {
            absClosest = absDistance
            closestChild = child
            scrollDistance = childCenter - containerCenter
        }
    }
    closestChild ?: return null

    // 滑动
    when (orientation) {
        VERTICAL -> mRecyclerView.smoothScrollBy(0, scrollDistance)
        HORIZONTAL -> mRecyclerView.smoothScrollBy(scrollDistance, 0)
    }
    return Pair(scrollDistance, lm.getPosition(closestChild))
}

(2)Fling

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

①、找到吸附目标的位置(adapter position)

open fun findTargetSnapPosition(
    lm: RecyclerView.LayoutManager,

    velocity: Int,

    helper: OrientationHelper

): Int {
    val itemCount: Int = lm.itemCount
    if (itemCount == 0) return RecyclerView.NO_POSITION
    
    // 中心点以前距离最近的View
    var closestChildBeforeCenter: View? = null
    var distanceBefore = Int.MIN_VALUE	// 中心点以前,距离为负数
    // 中心点以后距离最近的View
    var closestChildAfterCenter: View? = null
    var distanceAfter = Int.MAX_VALUE	// 中心点以后,距离为正数
    val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

    val childCount: Int = lm.childCount
    for (i in 0 until childCount) {
        val child = lm.getChildAt(i) ?: continue
        if (consumeSnap(i, child)) return RecyclerView.NO_POSITION // consumeSnap 默认返回false,竖直滑动模式才使用
        
        val childCenter = (helper.getDecoratedStart(child)
                + helper.getDecoratedMeasurement(child) / 2)
        val distance = childCenter - containerCenter

        // Fling需要考虑方向,先获取两个方向最近的View
        if (distance in (distanceBefore + 1)..0) {
            distanceBefore = distance
            closestChildBeforeCenter = child
        }
        if (distance in 0 until distanceAfter) {
            distanceAfter = distance
            closestChildAfterCenter = child
        }
    }
	
    // 根据方向选择Fling到哪个View
    val forwardDirection = velocity > 0
    if (forwardDirection && closestChildAfterCenter != null) {
        return lm.getPosition(closestChildAfterCenter)
    } else if (!forwardDirection && closestChildBeforeCenter != null) {
        return lm.getPosition(closestChildBeforeCenter)
    }

    // 边界情况处理
    val visibleView =
        (if (forwardDirection) closestChildBeforeCenter else closestChildAfterCenter)
            ?: return RecyclerView.NO_POSITION
    val visiblePosition: Int = lm.getPosition(visibleView)
    val snapToPosition = (visiblePosition - 1)

    return if (snapToPosition < 0 || snapToPosition >= itemCount) {
        RecyclerView.NO_POSITION
    } else snapToPosition
}

②、使用RecyclerView的「LinearSmoothScroller」完成吸附动画

private fun createScroller(
    oh: OrientationHelper
): LinearSmoothScroller {
    return object : LinearSmoothScroller(mRecyclerView.context) {
        override fun onTargetFound(
            targetView: View,
            state: RecyclerView.State,
            action: Action
        ) {
            val d = distanceToCenter(targetView, oh)
            val time = calculateTimeForDeceleration(abs(d))
            if (time > 0) {
                when (orientation) {
                    VERTICAL -> action.update(0, d, time, mDecelerateInterpolator)
                    HORIZONTAL -> action.update(d, 0, time, mDecelerateInterpolator)
                }
            }
        }

        override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) =
            100f / displayMetrics.densityDpi

        override fun calculateTimeForScrolling(dx: Int) =
            100.coerceAtMost(super.calculateTimeForScrolling(dx))
    }
}

protected fun distanceToCenter(targetView: View, helper: OrientationHelper): Int {
    val childCenter = (helper.getDecoratedStart(targetView)
            + helper.getDecoratedMeasurement(targetView) / 2)
    val containerCenter = helper.startAfterPadding + helper.totalSpace / 2
    return childCenter - containerCenter
}

完整操作:

protected fun snapFromFling(
    lm: RecyclerView.LayoutManager,

    velocity: Int,

    helper: OrientationHelper

): Pair<Boolean, Int> {
    val targetPosition = findTargetSnapPosition(lm, velocity, helper)
    if (targetPosition == RecyclerView.NO_POSITION) return Pair(false, 0)
    val smoothScroller = createScroller(helper)
    smoothScroller.targetPosition = targetPosition
    lm.startSmoothScroll(smoothScroller)
    return Pair(true, targetPosition) // 消费fling
}

2、覆盖效果实现

(1)如果使用PageTransform实现

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

image.png

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

for (i in 0 until 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)扩展RecyclerView实现覆盖翻页

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

image.png

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

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

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

override fun attach() {
    super.attach()
    mRecyclerView.setChildDrawingOrderCallback(this)
}

override fun onGetChildDrawingOrder(childCount: Int, i: Int) = childCount - i - 1 // 反向绘制

三、竖直滑动(Scroll Mode)

垂直.gif

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

1、跨章吸附

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

companion object {
    const val TYPE_NONE = 100	  // 其他
    const val TYPE_FIRST_PAGE = 101  // 首页
    const val TYPE_LAST_PAGE = 102   // 末页
}




fun bind() { // onBindViewHolder 时调用
	itemView.tag = when {
    	textPage.isLastPage -> TYPE_LAST_PAGE
    	textPage.isFirstPage -> TYPE_FIRST_PAGE
    	else -> TYPE_NONE
	}
	......
}

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

// 如果不是最后一页,则消费Snap(不进行吸附操作)
override fun consumeSnap(index: Int, child: View) =
    index == 0 && child.tag != ReadBookAdapter.TYPE_LAST_PAGE

2、跨章Fling阻断

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

  • 如果向上滑动,判断第一个可见View是否「末页」,如果是,smoothScroll到第二个可见View
  • 如果向下滑动,判断第二个可见View是否「首页」,如果是,smoothScroll到第一个可见View
private var inFling = false     // 正在fling,在OnFlingListener中设置为true
private var inBlocking = false  // 阻断fling


override val mScrollListener = object : RecyclerView.OnScrollListener() {
    var mScrolled = false



    override fun onScrollStateChanged(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
                } else if (mScrolled) {
                    mScrolled = false
                    snapToTargetExistingView(orientationHelper.value)
                }

            }

        }

    }


    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        if (dy != 0) {
            if (!mScrolled) {
                this@VSnapHelper.mCallback.onScrollBegin()
                mScrolled = true
            }
            val lm = mRecyclerView.layoutManager ?: return
            // fling阻断
            if (inFling && !inBlocking) {
                val child: View?
                val type: 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
                        val d = distanceToCenter(it, orientationHelper.value)
                        mRecyclerView.smoothScrollBy(0, d)
                    }
                }
            }
        }
    }
}

四、仿真页(Flip Mode)

仿真.gif

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

  1. 确认手指滑动方向
  2. 所有可见View都跟随屏幕
  3. 绘制次序根据拖拽方向改变,保证目标页在当前页之上
  4. 绘制仿真页
  5. 手指抬起后的翻页动画(确认Fling、Scroll Idle产生的两种Snap的方向,因为手指会来回滑动导致方向判断错误)

1、确认手指滑动方向

滑动方向不能直接在 onTouchdispatchTouchEvent 这些方法中直接判断,
因为极微小的滑动都会决定方向,这样会造成轻微触碰就判定了方向,导致页面内容闪动、抖动等问题。
我们需要在滑动了一定距离后确定方向,最好的选择就是在 onPageScroll 中进行判断,系统为我们保证了ScrollState已变为DRAGGING,此时用户100%已经在滑动。可以看下源码真正触发「onPageScroll」的条件有哪些

image.png

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

// 在onScrolled中调用
// mCurrentItem:onPageSelected中赋值,代表当前Item
// position:第一个可见View的位置
// offsetPx:第一个可见View的left取负
// mForward:方向,true为画布向左滑动(向尾部滑动),false画布向右滑动(向头部滑动)
private fun dispatchScrolled(position: Int, offsetPx: Int) {
    if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING) {
        mForward = mCurrentItem == position
    }
    mCallback.onPageScrolled(position, mCurrentItem, offsetPx, mForward)
}

image.png

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

override fun dispatchTouchEvent(e: MotionEvent): Boolean {
    if (snapHelper.mScrollState == RecyclerView.SCROLL_STATE_SETTLING) {
        return true // sellting过程中禁止滑动
    }
    delegate.onTouch(e)
    return super.dispatchTouchEvent(e)
}

2、遮盖效果

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

// onPageScrolled中调用
private fun transform(offsetPx: Int, firstVisible: Int) {
    val count = layoutManager.childCount
    if (count == 2 || (count == 3 && offsetPx == 0)) {
		// 可见View只有一个的时候,全部复位
        for (i in 0 until count) {
            layoutManager.getChildAt(i)?.also { view ->
                view.translationX = 0f
            }
        }
    } else {
        var target = 1
        if (count == 3 && firstVisible == 0) target-- // 首位适配,currentItem=0且存在滑动的时候
        for (i in 0 until 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
                }

            }

        }

    }

}

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

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

// 画布左移则反向绘制,右移则正想绘制
override fun getDrawingOrder(childCount: Int, i: Int) =
    if (snapHelper.mForward) childCount - i - 1 else i

4、绘制仿真页

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

(1)生成截图

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

// 生成截图方法
fun View.screenshot(): Bitmap? {
    return runCatching {
        val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
        val c = Canvas(screenshot)
        c.translate(-scrollX.toFloat(), -scrollY.toFloat())
        draw(c)
        screenshot
    }.getOrNull()
}

private var isBeginDrag = false

override fun onPageStateChange(state: Int) {
    when (state) {
        RecyclerView.SCROLL_STATE_DRAGGING -> {
            isBeginDrag = true
        }
    }
}

override fun onPageScrolled(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 else AnimDirection.PREV)
        }
        invalidate()
    }
}

(2)绘制仿真页

绘制仿真页参考 gedoor/legadoSimulationPageDelegate

  • 基础知识:三角函数、Android的矩阵、贝塞尔曲线、canvas.clipPath的 XOR & INTERSECT 模式
  • 绘制方法:Android仿真翻页:cnblogs.com
  • 计算方法:使用手指触摸点和触摸点对应的角位置(比如触摸点靠近右下角,角位置就是右下角),这两个点可以算出所有参数

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

5、动画控制

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

override fun computeScroll() {
    if (scroller.computeScrollOffset()) {
        setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat())
    } else if (isStarted) {
        stopScroll()
    }
}

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

// 选中时开始动画,此时position改变
override fun onPageSelected(position: Int) {
    val page = adapter.data[position]
    ReadBook.onPageChange(page)
    if (canDraw) {
        delegate.onAnimStart(300, false)
    }
}

// position未改变的情况
override fun onSnap(isFling: Boolean, forward: Boolean, changePosition: Boolean) {
    if (!changePosition) {
        delegate.onAnimStart(
            300,
            true,
            // 未改变方向,向前则播放向后动画
            if (forward) AnimDirection.PREV else AnimDirection.NEXT
        )
    }
}

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

// Scroll
override fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
    mSnapping = true
    super.snapToTargetExistingView(helper)?.also {
        // first为滑动距离,second为目标Item的position
        mCallback.onSnap(false, it.first > 0, mCurrentItem != it.second)
        return it
    }
    return null
}


// Fling
override val mFlingListener = object : RecyclerView.OnFlingListener() {
    override fun onFling(velocityX: Int, velocityY: Int): Boolean {
        val lm = mRecyclerView.layoutManager ?: return false
        mRecyclerView.adapter ?: return false
        val minFlingVelocity = mRecyclerView.minFlingVelocity
        val result = snapFromFling(
            lm,
            velocityX,
            orientationHelper.value
        )
        val consume = abs(velocityX) > minFlingVelocity && result.first
        if (consume) {
            mSnapping = true
            // second为目标Item的position,这里直接通过速度正负来判断方向
            mCallback.onSnap(true, velocityX > 0, result.second != mCurrentItem)
        }
        return consume
    }
}

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

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY0ZL21o' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片