Android巢狀滑動-Behavior方案實戰及細節注意
筆者在2013年就收到Android巢狀滑動的UI效果需求,當時都是直接從監聽滑動事件分發做起,至今再次收到這種類似的需求,一直以來想更新下之前的實現方式,相對於Behavior封裝過的方案而言畢竟不夠優雅,現就介紹前後兩種方案。
老方案的思路
這種方式是相關api直接使用,其他的封裝方式(包括behavoir)都是基於此封裝而來,直接重寫父類(ViewGroup)的事件分發機制:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等方法,手動事件分發,當屬於邏輯外層滑動時候,進行攔截,滿足一定條件之後,再重新分發事件給相關子巢狀的滾動View。這裡面程式碼實現就不展示出來,有點歷史,思路在此,不過實現中會有些問題。
例如當重新把move事件分發給子View時,這時子View突然接受到move事件,沒有完整的流程經歷down事件會導致未初始化而不能響應move事件,就是常見的不能連續滑動的根本原因;其次就是攔截事件不要攔截down事件,會導致某個view點選事件不能響應,滑動都應該只是針對move事件攔截。Behavior方式
在說Behavior之前先簡單提下巢狀滑動在5.0之後新增的Api:NestedScrollingParent、NestedScrollingChild以及相應的Helper類,具體介紹不是重點,分別實現這些介面的父View和子View類就能夠實現父View對子View巢狀滑動的監聽,同時父View和子View之間不一定是直接的上下層關係,子View可以是父view下任意子View,例如NestedScrollView、RecyclerView、CoordinatorLayout(本文重點,下面再講)都分別實現這兩個介面中一個或兩個,當然我們可以自定義ViewGroup實現NestedScrollingParent來監聽子View的巢狀滑動,貼下程式碼:
CustomNestedScrollLinearLayout .class
public class CustomNestedScrollLinearLayout extends LinearLayout implements NestedScrollingParent {
View mchild, mRecyc, mTitle;
public CustomNestedScrollLinearLayout(Context context) {
super(context);
}
public CustomNestedScrollLinearLayout (Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mchild = findViewById(R.id.move);
mRecyc = findViewById(R.id.recyclerView);
mTitle = findViewById(R.id.title);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
ViewGroup.LayoutParams params = mRecyc.getLayoutParams();
params.height = getMeasuredHeight() - findViewById(R.id.title).getMeasuredHeight();
}
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes) {
Log.i("onLayoutChild", "target=" + target.getHeight());
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
int bottom = mchild.getBottom();
int chileHeight = mchild.getHeight();
Log.i("onNestedScroll", "onNestedPreScroll dy=" + dy + " bottom=" + bottom);
if (dy > 0 && bottom > 0) {
int left = bottom - dy;
if (left >= 0) {
consumed[1] = dy;
} else {
consumed[1] =bottom;
}
mchild.offsetTopAndBottom(-consumed[1]);
mTitle.offsetTopAndBottom(-consumed[1]);
target.offsetTopAndBottom(-consumed[1]);
} }
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
Log.i("onNestedScroll", "onNestedScroll dyConsumed=" + dyConsumed + " dyUnconsumed=" + dyUnconsumed);
if (dyUnconsumed > 0) {
return;
}
int bottom = mchild.getBottom();
int chileHeight = mchild.getHeight();
if (dyUnconsumed < 0 && bottom < chileHeight) {
int left = bottom - dyUnconsumed;
int consumed;
if (left <= chileHeight) {
consumed = dyUnconsumed;
} else {
consumed = -chileHeight + bottom;
}
mchild.offsetTopAndBottom(-consumed);
mTitle.offsetTopAndBottom(-consumed);
target.offsetTopAndBottom(-consumed);
}
}
@Override
public void onStopNestedScroll(View child) {
Log.i("onNestedScroll", "onStopNestedScroll child=" + child.getClass().getSimpleName());
super.onStopNestedScroll(child);
}
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
Log.i("onNestedScroll", "onNestedPreFling target=" + target.getClass().getSimpleName() + " velocityY=" + velocityY);
return super.onNestedPreFling(target, velocityX, velocityY);
}
}
佈局程式碼:
<?xml version="1.0" encoding="utf-8"?>
<statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/move"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/colorAccent"
android:gravity="center"
android:text="Hello World!" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="@android:color/darker_gray"
android:gravity="center"
android:text="title" />
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" />
</statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout>
原理本文篇幅不夠,不想寫,之前都是寫關於原理篇,可以百度,今天搞個實戰篇,第一次貼程式碼,拿走就用,不謝。
當然這不是本文重點,確實基礎,上文提到了CoordinatorLayout,其中Behavior是就是CoordinatorLayout的靜態內部類,對其可以簡單理解為在CoordinatorLayout實現NestedScrollingParent2之後,接受到子View的滑動通知之後,把直接通過子View的Behavior來通知回撥(注意是直接子View,因為Behavior是CoordinatorLayout.LayoutParams的元素,只能解析直接子View的Behavoir配置),Behavior提供了很多回調,包括了巢狀滑動相關的介面方法。廢話不多說,上程式碼:
public class MyBehavior extends CoordinatorLayout.Behavior<View> {
private WeakReference<View> dependentView;
public MyBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
private View getDependentView() {
return dependentView.get();
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
if (dependency != null && dependency.getId() == R.id.move) {
dependentView = new WeakReference<>(dependency);
return true;
}
return super.layoutDependsOn(parent, child, dependency);
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
child.setTranslationY(dependency.getHeight() + dependency.getTranslationY());
return true;
}
@Override
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) {
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
if (dy < 0) {
return;
}
View dependentView = getDependentView();
float newTranslateY = dependentView.getTranslationY() - dy;
float minHeaderTranslate = -(dependentView.getHeight());
Log.i("onLayoutChild", "onNestedPreScroll dy=" + dy + "TranslationY='" + dependentView.getTranslationY());
if (newTranslateY >= minHeaderTranslate) {
dependentView.setTranslationY(newTranslateY);
consumed[1] = dy;
} else {
if (dependentView.getTranslationY() >= -minHeaderTranslate) {
consumed[1] = (int) (dependentView.getTranslationY() - minHeaderTranslate);
}
dependentView.setTranslationY(minHeaderTranslate);
}
}
@Override
public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
if (dyUnconsumed > 0) {
return;
}
View dependentView = getDependentView();
float currentTranslationY = dependentView.getTranslationY();
float newTranslateY = currentTranslationY - dyUnconsumed;
final float maxHeaderTranslate = 0;
Log.i("onLayoutChild", "onNestedScroll dyUnconsumed=" + dyUnconsumed + "currentTranslationY="+currentTranslationY);
if (newTranslateY <= maxHeaderTranslate) {
dependentView.setTranslationY(newTranslateY);
} else {
dependentView.setTranslationY(maxHeaderTranslate);
}
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type);
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
return super.onLayoutChild(parent, child, layoutDirection);
}
@Override
public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
return super.onMeasureChild(parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
}
}
佈局程式碼:
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/move"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/colorAccent"
android:gravity="center"
android:text="Hello World!" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="@android:color/darker_gray"
android:gravity="center"
android:text="title"
/>
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
/>
</LinearLayout>
</android.support.design.widget.CoordinatorLayout>
效果圖
這裡面通過滑動下面的list,會先讓紅色區域的Hello World!先移動,直到消失之後,list才滑動,中間連貫,不中斷,連續滑動,title停在頂層不動。基本滿足個人的需求,直接但是如果上面紅色header過長話,希望能通過滑動header(Helll World!區域)也能滑動整頁,而不是僅僅通過列表滑動來觸發的滑動,這時候巢狀滑動就不夠滿足。上文也提到了Behavior有好多其他回撥介面,要想實現Header滑動導致整頁滑動,故此我們必須監聽Header上面的滑動事件觸發,肯定會想到重寫Header的事件分發,這會顯得麻煩。Behavior就提供了View滑動事件的攔截監聽,直接貼程式碼。
@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
int dy = (int) ev.getY();
// Log.i("chuan", "onInterceptTouchEvent=" + ev.getAction() + "dy=" + dy);
View dependView = getDependentView();
if (dependView == null) {
return super.onInterceptTouchEvent(parent, child, ev);
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
lastY = dy;
if (Math.abs(lastY - downY) > 1 && dy < (dependView.getMeasuredHeight() + dependView.getTranslationY())) {
return true;
}
break;
default:
break;
}
return super.onInterceptTouchEvent(parent, child, ev);
}
int lastY;
@Override
public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
acquireVelocityTracker(ev);
final VelocityTracker verTracker = mVelocityTracker;
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
int dy = y - lastY;
// Log.i("chuan", "onTouchEvent=" + ev.getAction() + "dy=" + dy);
if (dy < 0) {
moveUp(-dy, new int[2]);
} else {
movedown(-dy);
}
lastY = y;
break;
case MotionEvent.ACTION_CANCEL:
//自動
verTracker.computeCurrentVelocity(1000, mMaxVelocity);
autoFlingBySpeedIfNedd(-verTracker.getYVelocity());
releaseVelocityTracker();
break;
default:
break;
}
return super.onTouchEvent(parent, child, ev);
}
重寫Behavior中onInterceptTouchEvent等方法,判斷手勢啟動位置,如果Header沒有消失,就攔截Move事件,讓header移動,header移動之後其dependVIew子View就會跟著滑動,從而實現整頁的滑動。
- 細節
-CoordinatorLayout中子View 佈局中屬性增加MarginBottom或top會導致下面的依賴view之間有重疊覆蓋。如上文中的Header若新增margin,會導致其依賴的view之間發生重疊,這個應該是CoordinatorLayout在layout子View時候沒有計算上下間距。
2、多個依賴view之間的佈局,第3個view要減去第二個view的高度。例如上列中佈局可以看到title和list都在一層父佈局中,但是如果希望就是都在CoordinatorLayout中該怎麼實現,佈局如下:
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/move"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/colorAccent"
android:gravity="center"
android:text="Hello World!" />
<!-- <LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior">-->
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="@android:color/darker_gray"
android:gravity="center"
android:text="title"
app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" />
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
app:layout_behavior="statistics.ymm.com.myapplication.Titlebehavior" />
<!-- </LinearLayout>-->
</android.support.design.widget.CoordinatorLayout>
這時候就要增加之後佈局的依賴關係了,title移動是依賴Header,設定MyBehavior配置,而list就要跟著title移動繼續,新增Titlebehavior,讓其依賴title,程式碼如下:
public class Titlebehavior extends CoordinatorLayout.Behavior {
public Titlebehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
private WeakReference<View> dependentView;
private View getDependentView() {
return dependentView.get();
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
if (dependency != null && dependency.getId() == R.id.title) {
dependentView = new WeakReference<>(dependency);
return true;
}
return super.layoutDependsOn(parent, child, dependency);
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
child.setTranslationY(dependency.getTranslationY());
return true;
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
child.layout(0, (int) TypedValue.applyDimension(1, 20, child.getResources().getDisplayMetrics()), parent.getWidth(), (int) (parent.getHeight()));
return true;
}
return super.onLayoutChild(parent, child, layoutDirection);
}
}
這個時候要注意onLayoutChild對list控制元件layOut時候要手動減去依賴VIew的高度,也就是title,否則會導致直接覆蓋了title。