仿寫豆瓣詳情頁(一)開篇
仿寫豆瓣詳情頁(二)底部浮層
仿寫豆瓣詳情頁(三)內容列表
仿寫豆瓣詳情頁(四)彈性佈局
仿寫豆瓣詳情頁(五)聯動和其餘細節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
就能夠作到豆瓣的效果。代碼比較簡單,就再也不贅述了。
嵌套滾動在處理嵌套同方向的滾動是十分高效的,和 仿寫豆瓣詳情頁(二)底部浮層、仿寫豆瓣詳情頁(三)內容列表 中攔截全部事件再分發滾動相比,可以更好的處理優先級的問題,不過代碼也更加複雜一點,具體實踐中怎麼選擇還要看具體場景,能用就行。