歡迎光臨
每天分享高質量文章

知乎裡的一些特效實現 無縫拖拽的 Layout

作者:JarvisBlog

連結:https://jarvisgg.github.io/2018/12/05/NestTouchScrollingLayout%20無縫滑動的%20Layout/

前言

今年年初接觸回答頁面改版,由之前的左右滑動回答改為上下滑動回答,由於當時回答頁的程式碼太過於龐大,所以第一次改版復用了之前的 UI 框架,外層 ViewPager + Fragment,內層是 WebView 巢狀 Hybrid 頁面。

問題出現了,WebView 可以滾動的時候,會持有整個 Touch 事件流程,導致當 webView 拖拽到底部,手指不脫離螢幕繼續拖拽的時候,無法將當前的拖拽操作給翻頁器,產生體驗上的割裂感。

接下來就是 UI 互動最佳化的歷程。

調研

1、NestedScrolling:

 

Support V4 提供了一套 API 來支援嵌入的滑動效果。NestedScrolling 提供了一套父 View 和子 View 滑動互動機制。要完成這樣的互動,父 View 需要實現 NestedScrollingParent 介面,而子 View 需要實現 NestedScrollingChild 介面。

作為一個可以嵌入 NestedScrollingChild 的父 View,需要實現 NestedScrollingParent,這個介面方法和 NestedScrollingChild 大致有一一對應的關係。同樣,也有一個 NestedScrollingParentHelper 輔助類來默默的幫助你實現和 Child 互動的邏輯。滑動動作是 Child 主動發起,Parent 就收滑動回呼並作出響應。

從上面的 Child 分析可知,滑動開始的呼叫 startNestedScroll(),Parent 收到 onStartNestedScroll() 回呼,決定是否需要配合 Child 一起進行處理滑動,如果需要配合,還會回呼 onNestedScrollAccepted()。

每次滑動前,Child 先詢問 Parent 是否需要滑動,即 dispatchNestedPreScroll(),這就回呼到 Parent 的 onNestedPreScroll(),Parent 可以在這個回呼中“劫持”掉 Child 的滑動,也就是先於 Child 滑動。

Child 滑動以後,會呼叫 onNestedScroll(),回呼到 Parent 的 onNestedScroll(),這裡就是 Child 滑動後,剩下的給 Parent 處理,也就是 後於 Child 滑動。

最後,滑動結束,呼叫 onStopNestedScroll() 表示本次處理結束。

這個方案其實很不錯,但最後被 pass 了,因為由於工程的原因,我們的 webview 是被包裹起來的,不可以任意去繼承 NestedScrollingChild 並做定製修改。

 

2、自定義 ViewGroup

 

其實目前的問題是當子 View scroll 到頂部或者底部的時候,無法將 Touch 事件流交還給父佈局。
因此這裡我採用的思路是透過我的 ViewGroup 去統一 dispatchTouchEvent 給我的子 View,條件就是,假如子 View 可以滾動,我就會構造一套完整的 touch 時間流分發給他。否則我會自己消化。

解決方案

 

step 1:

 

透過第二種方式的思路,我們第一步需要在我的 ViewGroup 攔截所有的 Touch 事件。所以…

 

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (isParentDispatchTouchEvent) {
return true;
else {
return super.onInterceptTouchEvent(ev);
}
}

上來我們就攔截出所有的 Touch 事件。

step2:

 

開始在 ViewGroup 的 onTouchEvent 處理所有相關的 Event。

 

// 1.初始記錄 Touch 坐標
int mDownY = event.getY();
int deltaY = 0;
// 2.預設子 View 持有事件流起始點 isHoldTouch = true,透過 isChildCanScroll 來判別當前子 View 是否可以滾動。
if (isHoldTouch && !isChildCanScroll(event, deltaY) && deltaY != 0) {
// 3.假如子 View 不可以滾動,當前 ViewGroup 需要阻斷 Touch 的下發,為了遵循 Touch 事件流的規範,當被外部阻斷時,需要對其下發 ACTION_CANEL。同時 isHoldTouch = false。
isHoldTouch = false;
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
getChildAt(0).dispatchTouchEvent(cancelEvent);
cancelEvent.recycle();
}
// 5.假如當我們在 ViewGroup 滾動過程中,滑動到了子 View 可滾動的狀態,這時候會將 ViewGroup 調整至滾動初始位置,然後對子 View 做一個 ACTION_DOWN 的操作,從而開始陸續分發子 View Touch 事件。同時 isHoldTouch = true。
if (!isHoldTouch && isChildCanScroll(event, deltaY) && deltaY != 0) {
setSheetTranslation(maxSheetTranslation);
isHoldTouch = true;
if (event.getAction() == MotionEvent.ACTION_MOVE) {
MotionEvent downEvent = MotionEvent.obtain(event);
downEvent.setAction(MotionEvent.ACTION_DOWN);
getChildAt(0).dispatchTouchEvent(downEvent);
downEvent.recycle();
}
}
if (isHoldTouch && deltaY != 0) {
// 6.當前判斷子 View 已經處於可分發 Touch 狀態,會陸續將 ACTION_MOVE 分發給他。從而實現子 View 的滾動。
event.offsetLocation(0, mSheetTranslation - mTouchParentViewOriginMeasureHeight);
getChildAt(0).dispatchTouchEvent(event);
else {
// 4.當上面阻斷完 Touch 的下發以後,這裡我們開始自己消化 Touch 事件,也就是這裡會做一個 TranslationY 修改,從而達到 ViewGroup 做 Y軸方向的偏移.
setSheetTranslation(newSheetTranslation);
if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) {
// 7.為了將這個結束事件後面分發給子 View
isHoldTouch = true;
}
}

step3:

判斷子 View 是否可以滾動

 

/**
* child can scroll
@param view
@param x
@param y
@param lockRect 是否開啟 允許 touch 脫離當前子 View 區域繼續生效。
@return
*/

protected boolean canScrollUp(View view, float x, float y, boolean lockRect) {
if (view instanceof WebView) {
return canWebViewScrollUp();
}
if (view instanceof ViewGroup) {
ViewGroup vg = (ViewGroup) view;
for (int i = 0; i             View child = vg.getChildAt(i);
int childLeft = child.getLeft() - view.getScrollX();
int childTop = child.getTop() - view.getScrollY();
int childRight = child.getRight() - view.getScrollX();
int childBottom = child.getBottom() - view.getScrollY();
boolean intersects = x > childLeft && x  childTop && y             if ((!lockRect || intersects)
&& canScrollUp(child, x - childLeft, y - childTop, lockRect)) {
return true;
}
}
}
if (view instanceof CoordinatorLayout &&
((CoordinatorLayout) view).getChildCount() > 0 &&
((CoordinatorLayout) view).getChildAt(0instanceof AppBarLayout) {
AppBarLayout layout = (AppBarLayout) ((CoordinatorLayout) view).getChildAt(0);
OnNestOffsetChangedListener listener = mOnOffsetChangedListener.get(layout.hashCode());
if (listener != null) {
if (listener.getOffsetY()  0) {
return true;
}
}
}
return view.canScrollVertically(-1);
}
/**
* child can scroll
@param view
@param x
@param y
@param lockRect 是否開啟 允許 touch 脫離當前子 View 區域繼續生效。
@return
*/

protected boolean canScrollDown(View view, float x, float y, boolean lockRect) {
if (view instanceof WebView) {
return canWebViewScrollDown();
}
if (view instanceof ViewGroup) {
ViewGroup vg = (ViewGroup) view;
for (int i = 0; i             View child = vg.getChildAt(i);
int childLeft = child.getLeft() - view.getScrollX();
int childTop = child.getTop() - view.getScrollY();
int childRight = child.getRight() - view.getScrollX();
int childBottom = child.getBottom() - view.getScrollY();
boolean intersects = x > childLeft && x  childTop && y             if ((!lockRect || intersects)
&& canScrollDown(child, x - childLeft, y - childTop, lockRect)) {
return true;
}
}
}
if (view instanceof CoordinatorLayout &&
((CoordinatorLayout) view).getChildCount() > 0 &&
((CoordinatorLayout) view).getChildAt(0instanceof AppBarLayout) {
AppBarLayout layout = (AppBarLayout) ((CoordinatorLayout) view).getChildAt(0);
OnNestOffsetChangedListener listener = mOnOffsetChangedListener.get(layout.hashCode());
if (listener != null) {
if (listener.getOffsetY()  0) {
return true;
}
}
}
return view.canScrollVertically(1);
}

這部分核心在於遞迴查詢當前 MotionEvent 當前坐標下的所有子 View 有沒有可以滾動的 View,從而根據 view.canScrollVertically 來進行判斷。

優勢

1、支援絕大多數的 View 巢狀滾動。
2、成本低,只需要在你想要巢狀滾動的 View 上麵包一層這個 Layout。

功能

 

1、支援巢狀滾動,無縫拖拽
2、支援 BottomSheet (使用方法詳見下方)
3、支援 Appbarlayout
3、支援拖拽阻尼 (使用方法詳見下方)

效果

 

normal

view+recyclerview

webview + recyclerview

question 

篇幅原因,部分效果圖未展示。

使用示例

normal use

 

<jarvis.com.library.NestedTouchScrollingLayout
android:id="@+id/wrapper"
android:layout_gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<android.support.v7.widget.RecyclerView
android:id="@+id/container_rv"
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="#fff"
android:overScrollMode="always">

android.support.v7.widget.RecyclerView>

jarvis.com.library.NestedTouchScrollingLayout>

 

// 設定手指下拉阻尼
mNestedTouchScrollingLayout.setDampingDown(2.0f / 5);
// 設定手指上拉阻尼
mNestedTouchScrollingLayout.setDampingUp(3.0f / 5);
mNestedTouchScrollingLayout.registerNestScrollChildCallback(new NestedTouchScrollingLayout.INestChildScrollChange() {

// 當前 Layout 偏移距離
@Override
public void onNestChildScrollChange(float deltaY) {
}

// finger 脫離螢幕 Layout 偏移量,以及當前 Layout 的速度
@Override
public void onNestChildScrollRelease(final float deltaY, final int velocityY) {
mNestedTouchScrollingLayout.recover(0new Runnable() {
@Override
public void run() {
Log.i("NestedTouchScrollingLayout ---> ""deltaY : " + deltaY + " velocityY : " + velocityY);
}
});
}
// 手指抬起時機
@Override
public void onFingerUp(float velocityY) {
}
// 橫向拖拽
@Override
public void onNestChildHorizationScroll(MotionEvent event, float deltaX, float deltaY) {

}
});

 

bottomsheet use

 

<jarvis.com.library.NestedTouchScrollingLayout
android:id="@+id/wrapper"
android:layout_marginTop="30dp"
android:layout_width="match_parent"
android:layout_height="match_parent">

<android.support.v7.widget.RecyclerView
android:background="#fff"
android:id="@+id/container_rv"
android:layout_width="match_parent"
android:layout_height="match_parent" />

jarvis.com.library.NestedTouchScrollingLayout>

 

 

// 臨界速度,根據業務而定
public static int mVelocityYBound = 1300;
// 規定 sheetView 彈起方向
mNestedTouchScrollingLayout.setSheetDirection(NestedTouchScrollingLayout.SheetDirection.BOTTOM);
mNestedTouchScrollingLayout.registerNestScrollChildCallback(new NestedTouchScrollingLayout.INestChildScrollChange() {
@Override
public void onNestChildScrollChange(float deltaY) {
}
@Override
public void onNestChildScrollRelease(final float deltaY, final int velocityY) {
int totalYRange = mNestedTouchScrollingLayout.getMeasuredHeight();
int helfLimit = (totalYRange - DisplayUtils.dpToPixel(BottomSheetActivity.this400)) / 2;
int hideLimit = totalYRange - DisplayUtils.dpToPixel(BottomSheetActivity.this400) / 2;
int helfHeight = totalYRange - DisplayUtils.dpToPixel(BottomSheetActivity.this400);
if (velocityY > mVelocityYBound && velocityY > 0) {
if (Math.abs(deltaY) > helfHeight) {
mNestedTouchScrollingLayout.hiden();
else {
mNestedTouchScrollingLayout.peek(mNestedTouchScrollingLayout.getMeasuredHeight() - DisplayUtils.dpToPixel(BottomSheetActivity.this,400));
}
else if (velocityY 0) {
if (Math.abs(deltaY)                 mNestedTouchScrollingLayout.expand();
else {
mNestedTouchScrollingLayout.peek(mNestedTouchScrollingLayout.getMeasuredHeight() - DisplayUtils.dpToPixel(BottomSheetActivity.this,400));
}
else {
if (Math.abs(deltaY) > hideLimit) {
mNestedTouchScrollingLayout.hiden();
else if (Math.abs(deltaY) > helfLimit) {
mNestedTouchScrollingLayout.peek(mNestedTouchScrollingLayout.getMeasuredHeight() - DisplayUtils.dpToPixel(BottomSheetActivity.this400));
else {
mNestedTouchScrollingLayout.expand();
}
}
}
@Override
public void onFingerUp(float velocityY) {
}
@Override
public void onNestChildHorizationScroll(MotionEvent event, float deltaX, float deltaY) {
}
});

 

引入

 

方式 1:

 

repositories {
// ...
maven { url "https://jitpack.io" }
}
dependencies {
implementation 'com.github.JarvisGG:NestedTouchScrollingLayout:v1.2.0'
}

方式 2:

 

repositories {
// ...
jcenter()
}
dependencies {
implementation 'com.jarvis.library.NestedTouchScrollingLayout:library:1.2.0'
}

原始碼:

https://github.com/JarvisGG/NestedTouchScrollingLayout

贊(0)

分享創造快樂