本文已授權微信公衆號:鴻洋(hongyangAndroid)在微信公衆號平臺原創首發。
最近在重新學習Android自定義View這一塊的內容,遇到了平時開發中經常碰到的一個棘手問題:View的滑動衝突。相信不少小夥伴都有相同的感覺,看似簡單真正做起來卻又不知道從何下手。今天就從一個簡單的Demo帶你徹底掌握解決View滑動衝突的辦法。
老規矩,先上圖:
示例圖中是一個常見的下拉回彈,手指向下滑動的時候,整個佈局會一起滑動。下拉到一定距離的時候鬆手,佈局會自動回彈到開始的位置;手指向上滑動的時候,佈局的子View會滑動到最底部,然後手指再向下滑動,佈局的子View會滑動到最頂部,最後手指繼續向下滑動,整個佈局會一起滑動,下拉到一定距離後鬆手自動回彈到開始位置。
最終實現的效果如上所示,一起看看怎樣一步步實現最終的效果:
一.佈局的下拉回彈實現
下拉回彈的實現本質其實就是View的滑動,目前Android中實現View的滑動可以分爲三種方式:通過改變View的佈局參數使得View重新佈局從而實現滑動;通過scrollTo/scrollBy方法來實現View的滑動;通過動畫給View施加平移效果來實現滑動。這裏我們採用第一種方式來實現,考慮到整個佈局是豎直排列,我們可以直接自定義一個LinearLayout來作爲父佈局。然後調用layout(int l, int t, int r, int b)方法重新佈局,達到滑動的效果。
public class MyParentView extends LinearLayout {
private int mMove;
private int yDown, yMove;
private int i = 0;
public MyParentView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
yDown = y;
break;
case MotionEvent.ACTION_MOVE:
yMove = y;
if ((yMove - yDown) > 0) {
mMove = yMove - yDown;
i += mMove;
layout(getLeft(), getTop() + mMove, getRight(), getBottom() + mMove);
}
break;
case MotionEvent.ACTION_UP:
layout(getLeft(), getTop() - i, getRight(), getBottom() - i);
i = 0;
break;
}
return true;
}
}
MotionEvent.ACTION_DOWN: 獲取剛開始觸碰的y座標
MotionEvent.ACTION_MOVE: 如果是向下滑動,計算出每次滑動的距離與滑動的總距離,將每次滑動的距離作爲layout(int l, int t, int r, int b)方法的參數,重新進行佈局,達到佈局滑動的效果。
MotionEvent.ACTION_UP: 將滑動的總距離作爲layout(int l, int t, int r, int b)方法的參數,重新進行佈局,達到佈局自動回彈的效果。
此時的佈局文件是這樣的:
<org.tyk.android.artstudy.MyParentView
android:id="@+id/parent_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider"></View>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="70dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginLeft="10dp"
android:background="@drawable/b" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginLeft="80dp"
android:text="回到首頁"
android:textSize="20sp" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="10dp"
android:background="@drawable/right_arrow" />
</RelativeLayout>
</org.tyk.android.artstudy.MyParentView>
中間重複的RelativeLayout就不貼出來了。至此,一個簡單的下拉回彈就已經實現了,關於快速滑動以及慣性滑動感興趣的可以加進去,這裏不是本篇博客的重點就不做討論了。
二.子View的滾動實現
手指向下滑動的時候,佈局的下拉回彈已經實現,現在我希望手指向上滑動的時候,佈局的子View能夠滾動。平時接觸最多的能滾動的View就是ScrollView,所以我的第一反應就是在自定義的LinearLayout內,添加一個ScrollView,讓子View能夠滾動。說幹就幹:
<org.tyk.android.artstudy.MyParentView
android:id="@+id/parent_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
</LinearLayout>
</ScrollView>
</org.tyk.android.artstudy.MyParentView>
興高采烈的加上去,最後運行的結果是:佈局完全變成了一個ScrollView,之前的下拉回彈效果已經完全消失!!!這顯然不是我期待的結果。
仔細分析一下這種現象,其實這就是常見的View滑動衝突場景之一:外部滑動方向與內部滑動方向一致。父佈局MyParentView需要響應豎直方向上的向下滑動,實現下拉回彈,子佈局ScrollView也需要響應豎直方向上的上下滑動,實現子View的滾動。當內外兩層都在同一個方向上可以滑動的時候,就會出現邏輯問題。因爲當手指滑動的時候,系統無法知道用戶想讓哪一層滑動。所以這種場景下的滑動衝突需要我們手動去解決。
解決辦法:
外部攔截法:外部攔截法是指點擊事件先經過父容器的攔截處理,如果父容器需要處理此事件就進行攔截,如果不需要此事件就不攔截,這樣就可以解決滑動衝突的問題。外部攔截法需要重寫父容器的onInterceptTouchEvent()方法,在內部做相應的攔截即可。
具體實現:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
yDown = y;
break;
case MotionEvent.ACTION_MOVE:
yMove = y;
if (yMove - yDown < 0) {
isIntercept = false;
} else if (yMove - yDown > 0) {
isIntercept = true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
return isIntercept;
}
實現分析:
在自定義的父佈局中重寫onInterceptTouchEvent()方法,MotionEvent.ACTION_MOVE的時候,進行判斷。如果手指是向上滑動,onInterceptTouchEvent()返回false,表示父佈局不攔截當前事件,當前事件交給子View處理,那麼我們的子View就能滾動;如果手指是向下滑動,onInterceptTouchEvent()返回true,表示父佈局攔截當前事件,當前事件交給父佈局處理,那麼我們父佈局就能實現下拉回彈。
三.連續滑動的實現
剛開始我以爲這樣就萬事大吉了,可後來我又發現一個很嚴重的問題:手指向上滑動的時候,子View開始滾動,然後手指再向下滑動,整個父佈局開始向下滑動,鬆手後便自動回彈。也就是說,剛纔滾動的子View已經回不到開始的位置。仔細分析一下其實這結果是意料之中的,因爲只要我手指是向下滑動,onInterceptTouchEvent()便返回true,父佈局會攔截當前事件。這裏其實又是上面提到的View滑動衝突:理想的結果是當子View滾動後,如果子View沒有滾動到開始的位置,父佈局就不要攔截滑動事件;如果子View已經滾動到開始的位置,父佈局就開始攔截滑動事件。
解決辦法:
內部攔截法:內部攔截法是指點擊事件先經過子View處理,如果子View需要此事件就直接消耗掉,否則就交給父容器進行處理,這樣就可以解決滑動衝突的問題。內部攔截法需要配合requestDisallowInterceptTouchEvent()方法,來確定子View是否允許父佈局攔截事件。
具體實現:
public class MyScrollView extends ScrollView {
public MyScrollView(Context context) {
this(context, null);
}
public MyScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
int scrollY = getScrollY();
if (scrollY == 0) {
//允許父View進行事件攔截
getParent().requestDisallowInterceptTouchEvent(false);
} else {
//禁止父View進行事件攔截
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
}
return super.onTouchEvent(ev);
}
}
實現分析:
自定義一個ScrollView,重寫onTouchEvent()方法,在MotionEvent.ACTION_MOVE的時候,得到滑動的距離。如果滑動的距離爲0,表示子View已經滾動到開始位置,此時調用 getParent().requestDisallowInterceptTouchEvent(false)方法,允許父View進行事件攔截;如果滑動的距離不爲0,表示子View沒有滾動到開始位置,此時調用 getParent().requestDisallowInterceptTouchEvent(true)方法,禁止父View進行事件攔截。這樣只要子View沒有滾動到開始的位置,父佈局都不會攔截事件,一旦子View滾動到開始的位置,父佈局就開始攔截事件,形成連續的滑動。
好了,針對其他場景更復雜的滑動衝突,解決滑動衝突的原理與方式無非就是這兩種方法。希望看完本篇博客能對你有所幫助,下一篇再見~~~
寫在最後:
昨天一直忙到下午纔有時間去看博客,看到這篇博客評論下面炸開了鍋。這裏有幾個問題說明一下:
關於Denon源碼的問題,因爲這個Demo的源碼不是單獨的,合集打包下來有30多M,所以當時就沒傳上去。我相信按照文章所說的步驟來,肯定會實現最後的效果,最後我上傳的源碼與文章代碼是一模一樣的,這一點我是百分百保證的。
關於Demo存在的問題,這個問題是真實存在的:
謝謝這位小夥伴,我當時也立即回覆了他,今天我把這個問題解決了。
public class MyScrollView extends ScrollView {
private scrollTopListener listener;
public void setListener(scrollTopListener listener) {
this.listener = listener;
}
public MyScrollView(Context context) {
this(context, null);
}
public MyScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
int scrollY = getScrollY();
if (scrollY == 0) {
//允許父View進行事件攔截
getParent().requestDisallowInterceptTouchEvent(false);
listener.scrollTop();
} else {
//禁止父View進行事件攔截
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
}
return super.onTouchEvent(ev);
}
public interface scrollTopListener {
void scrollTop();
}
}
給自定義的ScrollView添加一個接口,監聽是否滑到開始的位置。
public class MyParentView extends LinearLayout {
private int mMove;
private int yDown, yMove;
private boolean isIntercept;
private int i = 0;
private MyScrollView myScrollView;
private boolean isOnTop;
public MyParentView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
myScrollView = (MyScrollView) getChildAt(0);
myScrollView.setListener(new MyScrollView.scrollTopListener() {
@Override
public void scrollTop() {
isOnTop = true;
}
});
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
yDown = y;
break;
case MotionEvent.ACTION_MOVE:
yMove = y;
//上滑
if (yMove - yDown < 0) {
isIntercept = false;
//下滑
} else if (yMove - yDown > 0) {
isIntercept = true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
return isIntercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
yDown = y;
break;
case MotionEvent.ACTION_MOVE:
yMove = y;
if (isOnTop) {
yDown = y;
isOnTop = false;
}
if (isIntercept && (yMove - yDown) > 0) {
mMove = yMove - yDown;
i += mMove;
layout(getLeft(), getTop() + mMove, getRight(), getBottom() + mMove);
}
break;
case MotionEvent.ACTION_UP:
layout(getLeft(), getTop() - i, getRight(), getBottom() - i);
i = 0;
isIntercept = false;
break;
}
return true;
}
}
自定義的父佈局中,實現這個接口,然後在MotionEvent.ACTION_MOVE的時候,進行判斷:
if (isOnTop) {
yDown = y;
isOnTop = false;
}
如果滑動到頂部,就讓yDown的初始值爲(int) event.getY(),這樣就不會出現閃的問題,滑動也更加自然流暢。
關於Demo的優化與改進,我很感謝這位小夥伴:
他用不同的方式實現了一樣的效果,並且還把源碼發到了我的郵箱。實現的效果一模一樣,並且只用了自定義的父佈局加外部攔截法,貼一下代碼:
public class MyParentView extends LinearLayout {
private int mMove;
private int yDown, yMove;
private int i = 0;
private boolean isIntercept = false;
public MyParentView(Context context) {
super(context);
}
public MyParentView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MyParentView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private ScrollView scrollView;
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
scrollView = (ScrollView) getChildAt(0);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
onInterceptTouchEvent(ev);
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int y = (int) ev.getY();
int mScrollY = scrollView.getScrollY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
yDown = y;
break;
case MotionEvent.ACTION_MOVE:
yMove = y;
if (yMove - yDown > 0 && mScrollY == 0) {
if (!isIntercept) {
yDown = (int) ev.getY();
isIntercept = true;
}
}
break;
case MotionEvent.ACTION_UP:
layout(getLeft(), getTop() - i, getRight(), getBottom() - i);
i = 0;
isIntercept = false;
break;
}
if (isIntercept) {
mMove = yMove - yDown;
i += mMove;
layout(getLeft(), getTop() + mMove, getRight(), getBottom() + mMove);
}
return isIntercept;
}
}
這樣就不用自定義一個ScrollView,直接將原生的ScrollView放到這個父佈局中即可。大家可以試試他的方法,點個大大的贊。