一、全体逻辑
为何直接对RecyclerView
进行扩展而不运用ViewPager/ViewPager2
?原因如下:
- Scroll Model(笔直滑动)需求自定义主动滑动(对指定页进行吸附)
- Flip Mode(仿真翻页)需求获取各种状况下的方向信息,以完结更好的操控
- RecyclerView便利拓宽,一起三种形式一起运用RecyclerView完结,便于复用
完结逻辑:三种滑动形式都在RecyclerView
地根底上更改其滑动行为,横向滑动需求修改子View层级,仿真翻页需求再掩盖一层仿真动画
二、横向掩盖滑动(Slide Mode)
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完结
假如运用ViewPager
的PageTransform
,是能够完结掩盖动画的,完结思路:使可见View的第二个View跟从屏幕滑动
假设上图蓝色通明矩形为屏幕,其他为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
的完结办法
故咱们直接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)
竖直滑动需求滑动到跨章的方位时才吸附(主动回滚到指定方位),需求完结两个作用:跨章吸附、跨章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)
仿真页在横向掩盖滑动(Slide Mode)
根底之上完结,咱们还需求完结:
- 承认手指滑动方向
- 一切可见View都跟从屏幕
- 制作次第依据拖拽方向改动,确保方针页在当时页之上
- 制作仿真页
- 手指抬起后的翻页动画(承认Fling、Scroll Idle产生的两种Snap的方向,由于手指会来回滑动导致方向判别错误)
1、承认手指滑动方向
滑动方向不能直接在 onTouch
、dispatchTouchEvent
这些办法中直接判别,
由于极微小的滑动都会决定方向,这样会造成细微触碰就断定了方向,导致页面内容闪烁、颤动等问题。
咱们需求在滑动了必定间隔后确定方向,最好的选择便是在 onPageScroll
中进行判别,体系为咱们确保了ScrollState已变为DRAGGING,此刻用户100%已经在滑动。能够看下源码真正触发「onPageScroll」的条件有哪些
咱们完结的判别方向的代码:
// 在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)
}
不过这个规则在超快速滑动时会判别错误,即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
的作用,所以 LayoutManager
的 child
数量最多会有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/legado 的 SimulationPageDelegate
- 根底知识:三角函数、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()
}
}
对于Fling
和Scroll 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
}
}
(以上为一切关键点,只截取了部分代码,供给一个思路)