仿写豆瓣详情页(一)开篇
仿写豆瓣详情页(二)底部浮层
仿写豆瓣详情页(三)内容列表
仿写豆瓣详情页(四)弹性布局
仿写豆瓣详情页(五)联动和其余细节java
查看动图git
首先声明一下,这里说的「弹性布局」并非指的 FlexLayout
,而是上图所示的这种视图。在某个方向滚动到底,再进行滑动时,会滑出边界外的视图,松手后弹回,就像弹簧同样。这个视图的应用其实很普遍,开源方案也有不少,和「仿写豆瓣详情页」的关系并非很大,这里只是顺便造一个轮子,学习下嵌套滚动的知识。github
自定义弹性布局继承自 FrameLayout
,命名为 JellyLayout
。这里须要决定下如何布局和展现弹性拖拽时,拖拽方向的视图。数组
为了通用性,这里采用 topView
、bottomView
、leftView
和 rightView
等布局外视图,放在布局边界的上下左右四个方向,经过滚动整个视图的方式来露出对应的视图。ide
仿写豆瓣详情页(二)底部浮层、仿写豆瓣详情页(三)内容列表 的方案是尽量拦截事件,而后本身分发滚动。以前也说过这种方案的一个缺点,就是内部有嵌套滚动的视图时,没法准确肯定如何分发「滚动量」,由于这个时候应该由子 View 来分发事件。布局
这里采用嵌套滚动的方案,对嵌套滚动还不了解的能够参考下 自定义View事件之进阶篇(一)-NestedScrolling(嵌套滑动)机制,大致思想就是本身尽量不拦截事件,交给子 View 处理,而子 View 再滚动时会先通知父 View(需实现 NestedScrollingParent
接口),父 View 能够在滚动先后进行处理。post
设置上下左右视图的方法。学习
// ...
fun setTopView(v: View?): JellyLayout {
removeView(topView)
topView = v if (v != null) { addView(v) }
return this
}
// ...
复制代码
为了详细处理「滚动量」的分发和表示当前滚动的状态,除了 scrollX
、scrollY
等参数,咱们还须要知道边界外的哪一个区域视图显示了出来 currRegion
,显示了多少 currProcess
。动画
这里咱们定义下滚动的区域:this
JELLY_REGION_NONE
表示边界外的视图都没显示出来JELLY_REGION_TOP
表示顶部视图显示出来JELLY_REGION_BOTTOM
表示底部视图显示出来JELLY_REGION_LEFT
表示左边视图显示出来JELLY_REGION_RIGHT
表示右边视图显示出来同时还须要规定一次只有一个区域的视图会显示出来,以下图,左边的视图显示出来时,currRegion
是 JELLY_REGION_LEFT
,这个时候右边的视图就不会显示出来(废话),同时也不处理垂直方向的滚动,上下区域的视图也不会显示出来。
const val JELLY_REGION_NONE = 0
const val JELLY_REGION_TOP = 1
const val JELLY_REGION_BOTTOM = 2
const val JELLY_REGION_LEFT = 3
const val JELLY_REGION_RIGHT = 4
/** * 当前滚动所在的区域,一次只支持在一个区域滚动 */
@JellyRegion
var currRegion = JELLY_REGION_NONE get() = when {
scrollY < 0 -> JELLY_REGION_TOP
scrollY > 0 -> JELLY_REGION_BOTTOM
scrollX < 0 -> JELLY_REGION_LEFT
scrollX > 0 -> JELLY_REGION_RIGHT
else -> JELLY_REGION_NONE
}
private set
复制代码
说了区域,进度 currProcess
就很简单了,就是在 currRegion
的视图显示出来的比例(minScrollY
、maxScrollY
、minScrollX
、maxScrollX
是滚动的范围,以后会说)。这样经过 currRegion
和 currProcess
,咱们就可以精确而方便地知道弹性视图滚动的状态了,即哪一个区域的视图显示或滚动出来了多少。
/** * 当前区域的滚动进度 */
@FloatRange(from = 0.0, to = 1.0)
var currProcess = 0F
get() = when {
scrollY < 0 -> if (minScrollY != 0) { scrollY.toFloat() / minScrollY } else { 0F }
scrollY > 0 -> if (maxScrollY != 0) { scrollY.toFloat() / maxScrollY } else { 0F }
scrollX < 0 -> if (minScrollX != 0) { scrollX.toFloat() / minScrollX } else { 0F }
scrollX > 0 -> if (maxScrollX != 0) { scrollX.toFloat() / maxScrollX } else { 0F }
else -> 0F
}
private set
复制代码
为了支持一下外部自定义的动画,这里还支持进度的设置,即滚动到某个区域 region
的某个进度 process
,以及是否平滑滚动 smoothly
。smoothScrollTo
会利用 Scroller
作平滑的滚动,以后说。
fun setProcess( @JellyRegion region: Int, @FloatRange(from = 0.0, to = 1.0) process: Float = 0F,
smoothly: Boolean = true
) {
var x = 0
var y = 0
when (region) {
JELLY_REGION_TOP -> y = (minScrollY * process).toInt()
JELLY_REGION_BOTTOM -> y = (maxScrollY * process).toInt()
JELLY_REGION_LEFT -> x = (minScrollX * process).toInt()
JELLY_REGION_RIGHT -> x = (maxScrollX * process).toInt()
}
if (smoothly) {
smoothScrollTo(x, y)
} else {
scrollTo(x, y)
}
}
复制代码
一些更细节的配置和当前属性,方便外部作动画之类的。
/** * 上次 x 轴的滚动方向,主要用来判断是否发生了滚动 */
var lastScrollXDir: Int = 0
private set
/** * 上次 y 轴的滚动方向 */
var lastScrollYDir: Int = 0
private set
/** * 发生滚动时的回调 */
var onScrollChangedListener: ((JellyLayout)->Unit)? = null
/** * 复位时的回调,返回是否拦截处理复位事件 */
var onResetListener: ((JellyLayout)->Boolean)? = null
/** * 复位时的动画时间 */
var resetDuration: Int = 500
/** * 滚动的阻尼 */
var resistence = 2F
复制代码
布局时偷下懒,对于边界外的 View
没采用 laypout 的方式,而是属性动画的 translation。将边界外视图移动到对应侧的位置,同时根据对于 View
的宽高计算出滚动范围 minScrollY
、maxScrollY
、minScrollX
和 maxScrollX
。
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
topView?.also {
// 水平方向居中
it.x = (width - it.width) / 2F
// topView 的底部与弹性视图顶部对齐
it.y = -it.height.toFloat()
}
bottomView?.also {
it.x = (width - it.width) / 2F
it.y = height.toFloat()
}
leftView?.also {
it.x = -it.width.toFloat()
it.y = (height - it.height) / 2F
}
rightView?.also {
it.x = width.toFloat()
it.y = (height - it.height) / 2F
}
minScrollX = -(leftView?.width ?: 0)
maxScrollX = rightView?.width ?: 0
minScrollY = -(topView?.height ?: 0)
maxScrollY = bottomView?.height ?: 0
}
复制代码
滚动范围的限制比较简单,水平方向 minScrollX ~ maxScrollX
,垂直方向 minScrollY ~ maxScrollY
。
override fun canScrollHorizontally(direction: Int): Boolean {
return if (direction > 0) {
scrollX < maxScrollX
} else {
scrollX > minScrollX
}
}
override fun canScrollVertically(direction: Int): Boolean {
return if (direction > 0) {
scrollY < maxScrollY
} else {
scrollY > minScrollY
}
}
复制代码
在真正滚动时要复杂一些,因为 JELLY_REGION_NONE
和其余区域在滚动处理上逻辑不一样,简单来讲就是 JELLY_REGION_NONE
时不会拦截嵌套滚动的「滚动量」,而其余区域会拦截相应方向上的「滚动量」,所以须要按照区域进行限制。
举例来讲,内容视图是一个横向滚动的 View
,在 JELLY_REGION_LEFT
-> JELLY_REGION_RIGHT
的过程当中,左滑时先回到 JELLY_REGION_NONE
,而后通过内容视图本身滚动,滚到右边界,再往左滑才能到 JELLY_REGION_RIGHT
。而若是不按照 currRegion
进行滚到限制,就有可能直接从 JELLY_REGION_LEFT
滚到 JELLY_REGION_RIGHT
,这样内容视图是没有机会滚到的,会有问题,以下图。
/** * 具体滚动的限制取决于当前的滚动区域 [currRegion],这里的区域判断分得很细,可使得一次只处理一个区域的滚动, * 不然会存在在临界位置的一次大的滚动致使滚过了的问题。 * 具体规则: * [JELLY_REGION_LEFT] -> 只能在水平 [[minScrollX], 0] 范围内滚动 * [JELLY_REGION_RIGHT] -> 只能在水平 [0, [maxScrollX]] 范围内滚动 * [JELLY_REGION_TOP] -> 只能在垂直 [[minScrollY], 0] 范围内滚动 * [JELLY_REGION_BOTTOM] -> 只能在垂直 [0, [maxScrollY]] 范围内滚动 * [JELLY_REGION_NONE] -> 水平是在 [[minScrollX], [maxScrollX]] 范围内,垂直在 [[minScrollY], [maxScrollY]] */
override fun scrollTo(x: Int, y: Int) {
val region = currRegion
val xx = when(region) {
JELLY_REGION_LEFT -> x.constrains(minScrollX, 0)
JELLY_REGION_RIGHT -> x.constrains(0, maxScrollX)
else -> x.constrains(minScrollX, maxScrollX)
}
val yy = when(region) {
JELLY_REGION_TOP -> y.constrains(minScrollY, 0)
JELLY_REGION_BOTTOM -> y.constrains(0, maxScrollY)
else -> y.constrains(minScrollY, maxScrollY)
}
super.scrollTo(xx, yy)
}
private fun Int.constrains(min: Int, max: Int): Int = when {
this < min -> min
this > max -> max
else -> this
}
复制代码
按照 2 中说的,此次采用嵌套滚动方式,在拦截事件时就要能不拦截就不拦截。根据触点和滑动方向,找到对应方向能够进行嵌套滚动的视图 target
,若是右这样的视图,那就不拦截事件,走以后的嵌套滚动逻辑。
水平方向的查找方法即 findHorizontalNestedScrollingTarget
,深度优先遍历,查找触点下的、实现了 NestedScrollingChild
的、能够水平滚动的 View
,垂直方向的同理。
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
// move 时须要根据是否移动,是否有可处理对应方向移动的子 view,判断是否要本身拦截
MotionEvent.ACTION_MOVE -> {
val dx = (lastX - e.x).toInt()
val dy = (lastY - e.y).toInt()
lastX = e.x
lastY = e.y if (dx == 0 && dy == 0) {
false
} else {
val child = findChildUnder(e.rawX, e.rawY)
val target = if (abs(dx) > abs(dy)) {
child?.findHorizontalNestedScrollingTarget(e.rawX, e.rawY)
} else {
child?.findVerticalNestedScrollingTarget(e.rawX, e.rawY)
}
target == null
}
}
// ...
}
}
fun ViewGroup.findHorizontalNestedScrollingTarget(rawX: Float, rawY: Float): View? {
for (i in 0 until childCount) {
val v = getChildAt(i)
if (!v.isUnder(rawX, rawY)) {
continue
}
if (v is NestedScrollingChild
&& (v.canScrollHorizontally(1)
|| v.canScrollHorizontally(-1))) {
return v
}
if (v !is ViewGroup) {
continue
}
val t = v.findHorizontalNestedScrollingTarget(rawX, rawY)
if (t != null) {
return t
}
}
return null
}
复制代码
虽然主要是用嵌套滚动的方式处理,可是在内容视图不支持滚动时,仍是须要本身处理 touch 事件的。主要逻辑时计算 x 轴和 y 轴的「滚动量」,而后就行 dispatchScroll
分发,其返回是否处理。因为 JellyLayout
会与其余可滚动布局嵌套使用,在处理了「滚动量」后还须要用 requestDisallowInterceptTouchEvent(true)
请求父 View
不要拦截以后的事件。
override fun onTouchEvent(e: MotionEvent): Boolean {
return when (e.action) {
// ...
// move 时判断自身是否可以处理
MotionEvent.ACTION_MOVE -> {
val dx = (lastX - e.x).toInt()
val dy = (lastY - e.y).toInt()
lastX = e.x
lastY = e.y if (dispatchScroll(dx, dy)) {
// 本身能够处理就请求父 view 不要拦截事件
requestDisallowInterceptTouchEvent(true)
true
} else {
false
}
}
// ...
}
}
复制代码
dispatchScroll
会根据阻尼系数 resistence
,计算出各方向要处理的「滚动量」,而后根据 currRegion
决定进行水平仍是垂直方向的滚动,最后进行滚动。
/** * 分发滚动量,当滚动区域已知时,只处理对应方向上的滚动,未知时先经过滚动量肯定方向,再滚动 */
private fun dispatchScroll(dScrollX: Int, dScrollY: Int): Boolean {
val dx = (dScrollX / resistence).toInt()
val dy = (dScrollY / resistence).toInt()
if (dx == 0 && dy == 0) {
return true
}
val horizontal = when (currRegion) {
JELLY_REGION_TOP, JELLY_REGION_BOTTOM -> false
JELLY_REGION_LEFT, JELLY_REGION_RIGHT -> true
else -> abs(dScrollX) > abs(dScrollY)
}
return if (horizontal) {
if (canScrollHorizontally(dx)) {
scrollBy(dx, 0)
true
} else {
false
}
} else {
if (canScrollVertically(dy)) {
scrollBy(0, dy)
true
} else {
false
}
}
}
复制代码
这里实现了 NestedScrollingParent2
,用它主要是由于它的接口里增长了 NestedScrollType
注解标识的滚动的类型,取值以下,主要是用来区分滚动时来自手指滑动仍是 fling。
/** * Indicates that the input type for the gesture is from a user touching the screen. */
public static final int TYPE_TOUCH = 0;
/** * Indicates that the input type for the gesture is caused by something which is not a user * touching a screen. This is usually from a fling which is settling. */
public static final int TYPE_NON_TOUCH = 1;
复制代码
在一次嵌套滚动开始时会回调 onStartNestedScroll
,须要咱们返回是否处理此次嵌套滚动。这里和 系列的第二篇 里介绍的 BottomSheetLayout
同样,我不但愿 fling 影响容器视图的滚动,因此嵌套滚动也就只处理 TYPE_TOUCH
的。
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
// 只处理 touch 相关的滚动
return type == ViewCompat.TYPE_TOUCH
}
复制代码
在子 View
发生嵌套滚动时,会先回调到咱们的 onNestedScrollAccepted
,这里也没啥特殊处理。这里用到了一个嵌套滚动的帮助类 NestedScrollingParentHelper
,不过对于 NestedScrollingParent
来讲做用不大,这里很少赘述。
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
parentHelper.onNestedScrollAccepted(child, target, axes, type)
}
复制代码
在子 View
开始滚动前,会先回调 onNestedPreScroll
,咱们能够在这里进行拦截,将咱们消耗掉的「滚动量」赋值给 consumed
数组的对于位置。这里根据 currRegion
进行拦截处理,当处于水平的区域 JELLY_REGION_TOP
或 JELLY_REGION_BOTTOM
时,咱们只处理 y 轴滚动,能处理就消耗掉,垂直方向同理,最后进行分发。
/** * 根据滚动区域和新的滚动量肯定是否消耗 target 的滚动,滚动区域和处理优先级关系: * [JELLY_REGION_TOP] 或 [JELLY_REGION_BOTTOM] -> 本身优先处理 y 轴滚动 * [JELLY_REGION_LEFT] 或 [JELLY_REGION_RIGHT] -> 本身优先处理 x 轴滚动 */
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
when (currRegion) {
JELLY_REGION_TOP, JELLY_REGION_BOTTOM -> if (canScrollVertically(dy)) {
consumed[1] = dy
}
JELLY_REGION_LEFT, JELLY_REGION_RIGHT -> if (canScrollHorizontally(dx)) {
consumed[0] = dx
}
}
dispatchScroll(consumed[0], consumed[1])
}
复制代码
子 View
滚动以后会回调 onNestedScroll
,参数的意思也很明确,这里会告诉咱们子 View
消耗了多少「滚动量」,以及还有多少「滚动量」没有消耗。对于子 View
不消耗的滚动,咱们就本身分发。
override fun onNestedScroll( target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int ) {
dispatchScroll(dxUnconsumed, dyUnconsumed)
}
复制代码
一次嵌套滚动中止后会回调 onStopNestedScroll
,这里也没啥特殊处理,交给 NestedScrollingParentHelper
。
override fun onStopNestedScroll(target: View, type: Int) {
parentHelper.onStopNestedScroll(target, type)
}
复制代码
这样 onNestedPreScroll
和 onNestedScroll
结合就实现了嵌套滚动的主要处理逻辑:
currRegion
为 JELLY_REGION_NONE
,不会在 onNestedPreScroll
里拦截「滚动量」onNestedScroll
里子 View
有未消耗的「滚动量」时,咱们本身滚动,露出对应方向的边界外视图,currRegion
改变onNestedPreScroll
里就会拦截掉对应方向的「滚动量」进行分发currRegion
回到 JELLY_REGION_NONE
后,又回到 1JellyLayout
抬手默认会有一个回弹的逻辑,若是 currRegion
不是在 JELLY_REGION_NONE
、以前发生了移动、且未拦截回弹地处理 onResetListener
,就平滑地滚动到初始位置 smoothScrollTo(0, 0)
。
override fun dispatchTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
// ...
// up 或 cancel 时复位到原始位置,被拦截就再也不处理
// 在这里处理是由于自身可能并无处理任何 touch 事件,也就不能在 onToucheEvent 中处理到 up 事件
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
// 发生了移动,且不处于复位状态,且未被拦截,则执行复位操做
if ((lastScrollXDir != 0 || lastScrollYDir != 0)
&& currRegion != JELLY_REGION_NONE
&& onResetListener?.invoke(this) != true) {
smoothScrollTo(0, 0)
}
}
}
// ...
}
复制代码
onResetListener
的返回值是是否拦截回弹,做用主要是方便外部自定义的一些需求。好比拿 JellyLayout
作一个下拉刷新(固然可能还须要其余特殊处理),下拉一段后松手,就停留在某个位置,刷新完弹回;好比作一个像 iOS 那样左滑显示「删除」等操做。
setProcess
和回弹都用到了 smoothScrollTo
,仍是利用 Scroller
来作平滑滚动,固然了手指再次放下时还须要停掉 Scroller
。
/** * 利用 scroller 平滑滚动 */
private fun smoothScrollTo(x: Int, y: Int) {
if (scrollX == x && scrollY == y) {
return
}
scroller.startScroll(scrollX, scrollY, x - scrollX, y - scrollY, resetDuration)
invalidate()
}
/** * 计算并滚到须要滚动到的位置 */
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
scrollTo(scroller.currX, scroller.currY)
invalidate()
}
}
override fun dispatchTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
// down 时停掉 scroller 的滚动,复位滚动方向
MotionEvent.ACTION_DOWN -> {
scroller.abortAnimation()
lastScrollXDir = 0
lastScrollYDir = 0
}
// ...
}
// ...
}
复制代码
JellyLayout
对外暴露了区域 currRegion
和进度 currProcess
,同时也有发生滚动时的回调 onScrollChangedListener
,经过这些信息和一个受进度控制的自定义视图 RightDragToOpenView
就能够作到豆瓣的效果。代码比较简单,就再也不赘述了。
嵌套滚动在处理嵌套同方向的滚动是十分高效的,和 仿写豆瓣详情页(二)底部浮层、仿写豆瓣详情页(三)内容列表 中拦截全部事件再分发滚动相比,可以更好的处理优先级的问题,不过代码也更加复杂一点,具体实践中怎么选择还要看具体场景,能用就行。