在Android開發中,如果是一些簡單的布局,都很容易搞定,但是一旦涉及到復雜的頁面,特別是為了兼容小屏手機而使用了ScrollView以后,就會出現很多滑動事件的沖突,最經典的就是ScrollView中嵌套了ListView。今天主要總結一下這方面的知識點,也當作以后復習的筆記。
這里主要講述以下幾點:
- View的事件分發機制
- 事件滑動沖突的思路及方法
- ScrollView里面嵌套ViewPager滑動沖突問題
- ViewPager里面嵌套ViewPager滑動沖突問題
- Scrollview里面嵌套Listview滑動沖突問題
View的事件分發機制
關于View的事件分發機制講解網上一搜一大堆,所以本文不細描述,而是讓你理解主要的運行機制,當然也不是只是自己描述一下就結束了,會提供具體的博客參考,指引你去更詳細的了解。
View的事件分發機制說白了就是點擊事件的傳遞,也就是一個Down事件,若干個Move事件,一個Up事件構成的事件序列的傳遞。
先看下Down事件和Up事件的分發流程走向:
下面講述一下View事件分發機制涉及的幾個方法
- boolean dispatchTouchEcent(MotionEvent ev)
- boolean onInterceptTouchEvent(MotionEvent event)
- boolean onTouchEvent(MotionEvent event)
- public void requestDisallowInterceptTouchEvent(boolean disallowIntercept)
前三個方法的關系用下面偽代碼表示一下:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean consum = false;
if(onInterceptTouchEvent(ev)){
consum = onTouchEvent(ev);
}else{
consum = child.dispatchTouchEvent(ev);
}
return consum;
}
根據下面這幅圖逐個簡單介紹下上述的四個方法:
-
dispatchTouchEcent:
只要事件傳遞到了當前View,那么dispatchTouchEcent方法就一定會被調用,主要是用來分發事件的。返回結果表示是否消耗當前事件。
ture:事件就此消費,不會繼續往別的地方傳了,事件終止。
false:則回傳給父View的onTouchEvent事件處理。 -
onInterceptTouchEvent:
在dispatchTouchEcent方法內部調用此方法,用來判斷是否攔截某個事件。如果當前View攔截了某個事件,那么在這同一個事件序列中,此方法不會再次被調用(需要注意的是ViewGroup才有這個方法,View沒有onInterceptTouchEvent這個方法)。返回結果表示是否攔截當前事件。
true:攔截事件,則交給它的 onTouchEvent 來處理。
false:不攔截該事件,傳遞給子 view ,由子 view 的 dispatchTouchEvent 再來開始這個事件的分發。 -
onTouchEvent:
在dispatchTouchEcent方法內調用此方法,用來處理事件。返回結果表示是否處理當前事件。
true:表示消費該事件。
false:表示不處理,那么在同一個事件序列里面,當前View無法再收到后續的事件。 -
requestDisallowInterceptTouchEvent:
該方法中的參數disallowIntercept的意思就是childView告訴父容器要不要進行攔截。
true :告訴所有父控件不要攔截,事件交由childrenView處理;
false:告訴所有父控件攔截。在父控件的onInterceptTouchEvent()中可能類似這樣的處理。
這里總結一下:(結合下圖看)
事件總是從上往下進行分發,即先到達Activity,再到達ViewGroup,再到達子View,如果沒有任何視圖消耗事件的話,事件會順著路徑往回傳遞。
- 事件從Activity.dispatchTouchEvent()開始傳遞,只要沒有被停止或攔截,從最上層的View(ViewGroup)開始一直往下(子View)傳遞。子View 可以通過onTouchEvent()對事件進行處理。
- 事件由父View(ViewGroup)傳遞給子View,ViewGroup 可以通過onInterceptTouchEvent()對事件做攔截,停止其往下傳遞。
- 如果事件從上往下傳遞過程中一直沒有被停止,且最底層子View 沒有消費事件,事件會反向往上傳遞,這時父View(ViewGroup)可以進行消費,如果還是沒有被消費的話,最后會到Activity 的onTouchEvent()函數。
- 如果View 沒有對ACTION_DOWN 進行消費,之后的其他事件不會傳遞過來。
- OnTouchListener 優先于onTouchEvent()對事件進行消費。
如果還不理解這幾個方法的用處,請參考博文圖解 Android 事件分發機制,一定要有耐心仔細看,相信看完之后你會相信事件并沒有白白浪費的。
滑動沖突解決方案
滑動沖突的基本形式分為兩種,其他復雜的滑動沖突都是由這兩種基本形式演變而來:
- 外部滑動方向與內部方向不一致。
- 外部滑動方向與內部方向一致。
第一種可以理解為ScrollView 嵌套ViewPager,第二種可以理解為ViewPager嵌套ViewPager,稍后提供具體解決方案。
根據《Android開發藝術探索》講述滑動沖突的攔截方法有兩種:
外部攔截法
從父View著手,重寫onInterceptTouchEvent方法,在父View需要攔截的時候攔截,不需要則不攔截返回false。其偽代碼如下:
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int)event.getX();
int y = (int)event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (滿足父容器的攔截要求) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
在這里,首先down事件父容器必須返回false ,因為若是返回true,也就是攔截了down事件,那么后續的move和up事件就都會傳遞給父容器,子元素就沒有機會處理事件了。其次是up事件也返回了false,一是因為up事件對父容器沒什么意義,其次是因為若事件是子元素處理的,卻沒有收到up事件會讓子元素的onClick事件無法觸發。
內部攔截法
從子View入手,重寫子元素的dispatchTouchEvent方法,父View先不要攔截任何事件,所有的 事件傳遞給 子View,如果子View需要此事件就消費掉,不需要此事件的話就通過requestDisallowInterceptTouchEvent方法交給父View處理。偽代碼如下:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
parent.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器需要此類點擊事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
然后修改父容器的onInterceptTouchEvent方法:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
ScrollView里面嵌套ViewPager滑動沖突問題
- 外部攔截法
如上面所述,從 父ViewScrollView著手,重寫 OnInterceptTouchEvent方法,在上下滑動的時候攔截事件,在左右滑動的時候不攔截事件,返回 false,這樣確保子View 的dispatchTouchEvent方法會被調用,代碼如下:
public class VerticalScrollView extends ScrollView {
public VerticalScrollView(Context context) {
super(context);
}
public VerticalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public VerticalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(21)
public VerticalScrollView(Context context, AttributeSet attrs, int defStyleAttr, int
defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
private float mDownPosX = 0;
private float mDownPosY = 0;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final float x = ev.getX();
final float y = ev.getY();
final int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mDownPosX = x;
mDownPosY = y;
break;
case MotionEvent.ACTION_MOVE:
final float deltaX = Math.abs(x - mDownPosX);
final float deltaY = Math.abs(y - mDownPosY);
// 這里是夠攔截的判斷依據是左右滑動,讀者可根據自己的邏輯進行是否攔截
if (deltaX > deltaY) {
return false;
}
}
return super.onInterceptTouchEvent(ev);
}
}
- 內部攔截法
如上面上述,通過requestDisallowInterceptTouchEvent(true)方法來影響父View是否攔截事件,我們通過重寫ViewPager的 dispatchTouchEvent()方法,在左右滑動的時候請求父View ScrollView不要攔截事件,其他的時候攔截事件,代碼如下:
public class MyViewPager extends ViewPager {
private static final String TAG = "MyViewPager ";
int lastX = -1;
int lastY = -1;
public MyViewPager(Context context) {
super(context);
}
public MyViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getRawX();
int y = (int) ev.getRawY();
int dealtX = 0;
int dealtY = 0;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
dealtX = 0;
dealtY = 0;
// 保證子View能夠接收到Action_move事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
dealtX += Math.abs(x - lastX);
dealtY += Math.abs(y - lastY);
Log.i(TAG, "dealtX:=" + dealtX);
Log.i(TAG, "dealtY:=" + dealtY);
// 這里是夠攔截的判斷依據是左右滑動,讀者可根據自己的邏輯進行是否攔截
if (dealtX >= dealtY) {
getParent().requestDisallowInterceptTouchEvent(true);
} else {
getParent().requestDisallowInterceptTouchEvent(false);
}
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_CANCEL:
break;
case MotionEvent.ACTION_UP:
break;
}
return super.dispatchTouchEvent(ev);
}
}
ViewPager里面嵌套ViewPager滑動沖突問題
內部攔截法:
從子View ViewPager著手,重寫 子View的 dispatchTouchEvent方法,在子 View需要攔截的時候進行攔截,否則交給父View處理,代碼如下:
public class ChildViewPager extends ViewPager {
private static final String TAG = "ChildViewPager ";
public ChildViewPager(Context context) {
super(context);
}
public ChildViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int curPosition;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
curPosition = this.getCurrentItem();
int count = this.getAdapter().getCount();
Log.i(TAG, "curPosition:=" +curPosition);
// 當當前頁面在最后一頁和第0頁的時候,由父親攔截觸摸事件
if (curPosition == count - 1|| curPosition==0) {
getParent().requestDisallowInterceptTouchEvent(false);
} else {//其他情況,由孩子攔截觸摸事件
getParent().requestDisallowInterceptTouchEvent(true);
}
}
return super.dispatchTouchEvent(ev);
}
}
Scrollview里面嵌套Listview滑動沖突問題
ScrollView里面嵌套ListView,通常會出現以下兩個問題:
- ListView的高度顯示問題,常見的問題就是只顯示一行;
- ScrollView和ListView都有上下滑動事件,放在一起會存在滑動沖突。
常用方案有如下三種:
- 自定義ListView
public class ListViewForScroll extends ListView
{
public ListViewForScroll(Context context)
{
super(context);
}
public ListViewForScroll(Context context, AttributeSet attrs)
{
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
intexpandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2,
MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, expandSpec);
}
}
主要就是重載了onMeasure方法,改變了heightMeasureSpec。這里widthMeasureSpec和heightMeasureSpec用了32位的int作為參數,高2位代表模式,有三種UNSPECIFIED、EXACTLY、AT_MOST,這是自定義View的基礎知識。低30位代表數值。
MeasureSpec.makeMeasureSpec函數中第一個參數是高度的值,第二個參數是模式,makeMeasureSpec則是把模式和值合成為一個int值,這里賦給了高度。
Integer.MAX_VALUE >> 2是int類型取30位時的最大整數,即Integer.MAX_VALUE是int的最大32位值,再右移2位,就是30位,同樣是最大值,只不過是30位的最大值,所以在模式上也只能選擇MeasureSpec.AT_MOST。最終這個ListView的顯示高度會是其能顯示出來的最大值,所有的條目都會顯示出來。
優點:寫法簡單,不影響ListView使用。
缺點:
i. 由于高度設置成最大值,所有條目都會進行繪制,只是有些條目會在屏幕之外。舉個例子,我傳遞的數據有20條,但是屏幕只夠顯示10條,此時用自定義的ListView會調用20次getView把所有條目都繪制出來,完全放棄了ListView的復用機制,跟直接寫布局沒有什么區別了,會造成頁面加載速度緩慢的問題。
ii. ListView高度必須設置成match_parent。
- 動態測量ListView高度
public static void setListViewHeightBasedOnChildren(ListView listView) {
ListAdapter listAdapter = listView.getAdapter();
if (listAdapter == null) {
return;
}
int totalHeight = 0;
for (int i = 0; i < listAdapter.getCount(); i++) {
View listItem = listAdapter.getView(i, null, listView);
listItem.measure(0, 0);
totalHeight += listItem.getMeasuredHeight();
}
ViewGroup.LayoutParams params = listView.getLayoutParams();
params.height = totalHeight
+ (listView.getDividerHeight() * (listAdapter.getCount() - 1));
listView.setLayoutParams(params);
}
這里就是去獲取每個條目的View高度,然后所有子View高度相加得到總高度,并設置給ListView的LayoutParams。
優點:能夠實現功能需求。
缺點:
i. 每個條目的布局只能用LinearLayout,而不能用RelativeLayout,因為LinearLayout重寫了onMeasure方法,才能調用listItem.measure(0, 0)這句,而其他布局沒有。
ii. ListView高度必須設置成match_parent。
iii. 在ListView設置Adaper和調用notifyDataSetChanged時候都要調用該方法。
iv. 由于高度設置成最大值,所有條目都會進行繪制,跟第一個方法“自定義ListView”存在同樣的問題。
- 第三是自定義LinearLayout模擬ListView。
public class LinearLayoutListView extends LinearLayout { private BaseAdapter adapter; private MyOnItemClickListener onItemClickListener; boolean footerViewAttached = false; private View footerview; public LinearLayoutListView(Context context) { super(context); initAttr(null); } public LinearLayoutListView(Context context, AttributeSet attrs) { super(context, attrs); initAttr(attrs); } public void initAttr(AttributeSet attrs) { setOrientation(VERTICAL); } /** * 初始化footerview * * @param footerView */ public void initFooterView(final View footerView) { this.footerview = footerView; } /** * 設置footerView監聽事件 * * @param onClickListener */ public void setFooterViewListener(OnClickListener onClickListener) { this.footerview.setOnClickListener(onClickListener); } public BaseAdapter getAdapter() { return adapter; } /** * 設置adapter并模擬listview添加????數據 * * @param adpater */ public void setAdapter(BaseAdapter adpater) { this.adapter = adpater; removeAllViews(); if (footerViewAttached) addView(footerview); notifyChange(); } /** * 設置條目監聽事件 * * @param onClickListener */ public void setOnItemClickListener(MyOnItemClickListener onClickListener) { this.onItemClickListener = onClickListener; } /** * 沒有下一頁了 */ public void noMorePages() { if (footerview != null && footerViewAttached) { removeView(footerview); footerViewAttached = false; } } /** * 可能還有下一?? */ public void mayHaveMorePages() { if (!footerViewAttached && footerview != null) { addView(footerview); footerViewAttached = true; } } /** * 通知更新listview */ public void notifyChange() { int count = getChildCount(); if (footerViewAttached) { count--; } LayoutParams params = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT); for (int i = count; i < adapter.getCount(); i++) { final int index = i; final LinearLayout layout = new LinearLayout(getContext()); layout.setLayoutParams(params); layout.setOrientation(VERTICAL); View v = adapter.getView(i, null, null); v.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (onItemClickListener != null) { onItemClickListener.onItemClick(LinearLayoutListView.this, layout, index, adapter.getItem(index)); } } }); ImageView imageView = new ImageView(getContext()); imageView.setBackgroundResource(R.color.background); imageView.setLayoutParams(params); layout.addView(v); layout.addView(imageView); addView(layout, index); } } public static interface MyOnItemClickListener { public void onItemClick(ViewGroup parent, View view, int position, Object o); } }
生硬的實現了ListView的基礎功能,但是ListView的復用機制完全沒有,跟直接寫布局有何區別。
優點:能夠實現功能需求。
缺點:
i. ListView高度要設置成match_parent
ii. 由于高度設置成最大值,所有條目都會進行繪制,跟“自定義ListView”存在同樣的問題。
另外推薦解決滑動沖突方案的博文:
【Android】ListView、RecyclerView、ScrollView里嵌套ListView 相對優雅的解決方案:NestFullListView