Android自定義ViewGroup實(shí)現(xiàn)側(cè)滑菜單
前言
前文我們理解了ViewGroup的測(cè)量與布局,但是并沒有涉及到多少的交互邏輯,而 ViewGroup 的交互邏輯說起來范圍其實(shí)是比較大的。從哪開始說起呢?
我們暫且把 ViewGroup 的交互分為幾塊知識(shí)區(qū),
- 事件的攔截。
- 事件的處理(內(nèi)部又分不同的處理方式)。
- 子View的移動(dòng)與協(xié)調(diào)。
- 父ViewGroup的協(xié)調(diào)運(yùn)動(dòng)。
然后我們先簡單的做一個(gè)介紹,需要注意的是下面每一種方式單獨(dú)拿出來都是一個(gè)知識(shí)點(diǎn)或知識(shí)面,這里我個(gè)人理解的話,可以當(dāng)做一個(gè)目錄,我們先簡單的復(fù)習(xí)學(xué)習(xí)一下,心里過一遍,如果遇到哪一個(gè)知識(shí)點(diǎn)不是那么了解,那我們也可以單獨(dú)的對(duì)這個(gè)技術(shù)點(diǎn)進(jìn)行搜索與對(duì)應(yīng)的學(xué)習(xí)。
而本文介紹完目錄之后,我們會(huì)針對(duì)其中的一種【子View的協(xié)調(diào)運(yùn)動(dòng)】,也就是本文的側(cè)滑菜單效果做講解,后期也會(huì)對(duì)一些其他常用的效果再做分析哦。
話不多說,Let's go
一、常用的幾種交互方式
一般來說,常見的幾種場(chǎng)景通常來說涉及到如下的幾種方式。每一種方式又根據(jù)不同的效果可以分為不同的方式來實(shí)現(xiàn)。
需要注意的是有時(shí)候也并非唯一解,也可以通過不同的方式實(shí)現(xiàn)同樣的效果。也可以通過不同的方式組合起來,實(shí)現(xiàn)一些特定的效果。
下面我們先從事件的分發(fā)與攔截說起:
1.1 事件的攔截處理
自定義 ViewGroup 的一種分類,還比較常用的就是解決事件的沖突,常用的就是事件的攔截,這一點(diǎn)就需要了解一點(diǎn) View 的事件分發(fā)與攔截的機(jī)制了。不過相信大家多多少少都懂一點(diǎn),畢竟也是面試必出題了,下面簡單說一下。
事件分發(fā)方面的區(qū)別:
事件分發(fā)機(jī)制主要有三個(gè)方法:dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent()
ViewGroup包含這三個(gè)方法,而View則只包含dispatchTouchEvent()、onTouchEvent()兩個(gè)方法,不包含onInterceptTouchEvent()。
onTouchEvent() 與 dispatchTouchEvent() 相信大家都有所了解。
onTouchEvent() 是事件的響應(yīng)與處理,而dispatchTouchEvent() 是事件的分發(fā)。
需要注意的是當(dāng)某個(gè)子View的dispatchTouchEvent()返回true時(shí),會(huì)中止Down事件的分發(fā),同時(shí)在ViewGroup中記錄該子View。接下來的Move和Up事件將由該子View直接進(jìn)行處理。
而 onInterceptTouchEvent() 就是ViewGroup專有的攔截處理,雖然子 View 沒有攔截的方法,但是子View可以通過調(diào)用方法 getParent().requestDisallowInterceptTouchEvent() 請(qǐng)求父ViewGroup不攔截事件。
通過 重寫 onInterceptTouchEvent() 或者 使用 requestDisallowInterceptTouchEvent() 即可達(dá)到事件攔截的處理。
關(guān)于事件的處理這里可以引用一張圖,非常的清晰:

實(shí)際的應(yīng)用,我這里以 ViewPager2 嵌套 RecyclerView 的場(chǎng)景為例。

如圖所示的分類列表,我們可以使用ViewPager2 嵌套 RV 來實(shí)現(xiàn)。(具體的實(shí)現(xiàn)方式有多種,這里不做討論),那么就會(huì)出現(xiàn)一個(gè)問題。什么時(shí)候滾動(dòng)子 RV 。什么時(shí)候滾動(dòng)垂直的父 VP2 。如果大家有嘗試過類似的場(chǎng)景,相信大家就能理解這其中的坑點(diǎn),隨機(jī)出現(xiàn)父布局與子布局的滾動(dòng),也就是說有還是有事件沖突的問題。
就算大家使用別的方案解決了這個(gè)問題,那么換成一個(gè)復(fù)雜的分類列表又如何?

再比如這種復(fù)雜的分類頁面,由于數(shù)據(jù)量比較大,子 RV 的上拉滑動(dòng)事件中還需要加入上拉加載的時(shí)間。這一個(gè)分類滑動(dòng)完畢之后,還需要切換右上的橫向Tab。當(dāng)橫向Tab到最后一個(gè)了,并且滑動(dòng)完畢之后,左側(cè)的滾動(dòng)Tab才往下走一個(gè)。
面對(duì)如此復(fù)雜的分類列表滾動(dòng)邏輯,我們就需要使用自定義ViewGroup時(shí)間攔截層,自己控制什么時(shí)機(jī)由子 RV 控制滑動(dòng),什么時(shí)機(jī)由父 VP2 控制滑動(dòng)。
這里我們以上圖的簡單示例為主,也是默認(rèn)的常用效果,當(dāng)子 RV 滾動(dòng)完成之后再交由父 VP2 滾動(dòng)。我們定義的攔截層自定義ViewGroup如下:
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
}
return v as? ViewPager2
}
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
init {
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
return handleInterceptTouchEvent(e)
}
private fun handleInterceptTouchEvent(e: MotionEvent): Boolean {
val orientation = parentViewPager?.orientation ?: return false
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return false
}
if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
if (scaledDx > touchSlop || scaledDy > touchSlop) {
return if (isVpHorizontal == (scaledDy > scaledDx)) {
//垂直的手勢(shì)攔截
parent.requestDisallowInterceptTouchEvent(false)
true
} else {
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
//子View能滾動(dòng),不攔截事件
parent.requestDisallowInterceptTouchEvent(true)
false
} else {
//子View不能滾動(dòng),直接就攔截事件
parent.requestDisallowInterceptTouchEvent(false)
true
}
}
}
}
return false
}
}
這里主要的邏輯就是對(duì)攔截做處理,而如果是下圖中復(fù)雜的分類頁面,也是類似的邏輯,只是需要手動(dòng)的控制是否攔截了。可以實(shí)現(xiàn)同樣的效果的。
而除了攔截事件的自定義 ViewGroup 的場(chǎng)景之外,我們用的比較多的就是事件的處理了,事件的處理又分很多,可以自己手撕 onTouchEvent 。也可通過 Scroller 來實(shí)現(xiàn)滾動(dòng)效果。也能通過 GestureDetector 手勢(shì)識(shí)別器來幫我們完成。
下面一起來看看分別如何實(shí)現(xiàn):
1.2 自行處理事件的幾種方式
在之前的 View 和 ViewGroup 的學(xué)習(xí)中,我們一般都是自己來處理事件的響應(yīng)與攔截,一般都是通過 MotionEvent 對(duì)象,拿到它的事件和一些位置信息,做繪制和事件攔截。
其實(shí)除了這一種最基本的方式,還有其他的方式也同樣可以操作,分為不同的場(chǎng)景,我們可以選擇性的使用不同的方式,都可以達(dá)到同樣的效果。
onTouchEvent
我們比較常見的就是在 dispatchTouchEvent()、onTouchEvent() 兩個(gè)方法中通過 MotionEvent 對(duì)象來操作屬性。
比較常用的就是通過手勢(shì)記錄坐標(biāo)點(diǎn),然后進(jìn)行繪制,或者進(jìn)行事件的攔截。
例如,如果想繪制,我們可以記錄變化的X與Y,然后通過指定的公式轉(zhuǎn)換為繪制的變量,然后通過 invalidate 觸發(fā)重繪,在 onDraw 中取到變化的變量繪制出來,達(dá)到動(dòng)畫或滾動(dòng)或其他的一些效果。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
//按下的時(shí)候記錄當(dāng)前操作的是左側(cè)限制圓還是右側(cè)的限制圓
downX = event.getX();
touchLeftCircle = checkTouchCircleLeftOrRight(downX);
if (touchLeftCircle) {
//如果是左側(cè)
//如果超過右側(cè)最大值則不處理
if (downX + perSlice > mRightCircleCenterX) {
return false;
}
mLeftCircleCenterX = downX;
} else {
//如果是右側(cè)
//如果超過左側(cè)最小值則不處理
if (downX - perSlice < mLeftCircleCenterX) {
return false;
}
mRightCircleCenterX = downX;
}
}
//中間的進(jìn)度矩形是根據(jù)兩邊圓心點(diǎn)動(dòng)態(tài)計(jì)算的
mSelectedCornerLineRect.left = mLeftCircleCenterX;
mSelectedCornerLineRect.right = mRightCircleCenterX;
//全部的事件處理完畢,變量賦值完成之后,開始重繪
invalidate();
return true;
}或者我們可以通過記錄X和Y的坐標(biāo),判斷滑動(dòng)的方向從而進(jìn)行事件的攔截:
@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);
// 這里是否攔截的判斷依據(jù)是左右滑動(dòng),讀者可根據(jù)自己的邏輯進(jìn)行是否攔截
if (dealtX >= dealtY) { // 左右滑動(dòng)請(qǐng)求父 View 不要攔截
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);
}
這種方式相信也是大家見的最多的,看見代碼就知道是什么意思,所以這里就不放圖與Demo了,如果想了解,也可以看看我之前的自定義View繪制文章,基本都是這個(gè)套路。
接下來我們繼續(xù),那么除了原始的 MotionEvent 做移動(dòng)之外,我們甚至可以使用 Scroller 來專門做滾動(dòng)的操作。只是相對(duì)來說 Scroller 是比較少用的。(畢竟谷歌給我們的太多的滾動(dòng)的控件了),但是掌握之后可以實(shí)現(xiàn)一些特殊的效果,也是值得一學(xué),下面一起看看吧。
Scroller
Scroller 譯為滾動(dòng)器,是 ViewGroup 類中原生支持的一個(gè)功能。Scroller 類并不負(fù)責(zé)滾動(dòng)這個(gè)動(dòng)作,只是根據(jù)要滾動(dòng)的起始位置和結(jié)束位置生成中間的過渡位置,從而形成一個(gè)滾動(dòng)的動(dòng)畫。
Scroller 本身并不神秘與復(fù)雜,它只是模擬提供了滾動(dòng)時(shí)相應(yīng)數(shù)值的變化,復(fù)寫自定義 View 中的 computeScroll() 方法,在這里獲取 Scroller 中的 mCurrentX 和 mCurrentY,根據(jù)自己的規(guī)則調(diào)用 scrollTo() 方法,就可以達(dá)到平穩(wěn)滾動(dòng)的效果。
本質(zhì)上就是一個(gè)持續(xù)不斷刷新 View 的繪圖區(qū)域的過程,給定一個(gè)起始位置、結(jié)束位置、滾動(dòng)的持續(xù)時(shí)間,Scroller 自動(dòng)計(jì)算出中間位置和滾動(dòng)節(jié)奏,再調(diào)用 invalidate()方法不斷刷新。
需要注意的是調(diào)用scrollTo()和 scrollBy()的區(qū)別。其實(shí)也不復(fù)雜,我們翻譯為中文的意思,scrollTo是滾動(dòng)到xx,scrollBy是滾動(dòng)了xx,這樣是不是就一下就理解了。
剩下的就是需要重寫computeScroll執(zhí)行滾動(dòng)的邏輯。
下面舉個(gè)簡單的栗子:
我們使用 Scroller模仿一個(gè) 簡易的 ViewPager 效果。自定義ViewGroup中加入了9個(gè)View。并且占滿全屏,然后我們上滑動(dòng)切換布局,當(dāng)停手會(huì)判斷是回到當(dāng)前View還是去下一個(gè)View。
ViewGroup的測(cè)量與布局在之前的文章中我們已經(jīng)反復(fù)的復(fù)習(xí)了,這應(yīng)該沒什么問題:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int count = getChildCount();
for (int i = 0; i < count; i++) {
View childView = getChildAt(i);
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
//設(shè)置ViewGroup的高度
MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
mlp.height = mScreenHeight * childCount;
setLayoutParams(mlp);
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() != View.GONE) {
child.layout(l, i * mScreenHeight, r, (i + 1) * mScreenHeight);
}
}
}然后就是對(duì)Touch和滾動(dòng)的操作:
private int mLastY;
private int mStart;
private int mEnd;
private Scroller mScroller;
...
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
scrollTo(0, mScroller.getCurrY());
postInvalidate();
}
}
@Override
public boolean onTouch(View v, MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastY = y;
mStart = getScrollY();
break;
case MotionEvent.ACTION_MOVE:
//當(dāng)停止動(dòng)畫的時(shí)候,它會(huì)馬上滾動(dòng)到終點(diǎn),然后向動(dòng)畫設(shè)置為結(jié)束。
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
int dy = mLastY - y;
if (getScrollY() < 0) {
dy = 0;
}
//開始滾動(dòng)
scrollBy(0, dy);
mLastY = y;
break;
case MotionEvent.ACTION_UP:
mEnd = getScrollY();
int dScrollY = mEnd - mStart;
if (dScrollY > 0) {
if (dScrollY < mScreenHeight / 3) {
mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
} else {
mScroller.startScroll(0, getScrollY(), 0, mScreenHeight - dScrollY);
}
} else {
if (-dScrollY < mScreenHeight / 3) {
mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
} else {
mScroller.startScroll(0, getScrollY(), 0, -mScreenHeight - dScrollY);
}
}
invalidate();
break;
}
return true;
}那么實(shí)現(xiàn)的效果就是如下圖所示:

是不是相當(dāng)于一個(gè)簡配的ViewPager呢。。。
既然我們的一些事件點(diǎn)擊和移動(dòng)可以通過 MotionEvent 來實(shí)現(xiàn),一些特定的滾動(dòng)效果還能通過 Scroller 來實(shí)現(xiàn)。有沒有更方便的一種方式全部幫我們實(shí)現(xiàn)呢?
接下來就是我們常用的 GestureDetector 類了??梢詭椭覀兛焖賹?shí)現(xiàn)點(diǎn)擊與滾動(dòng)效果。
GestureDetector
GestureDetector類,這個(gè)類指明是手勢(shì)識(shí)別器,它內(nèi)部封裝了一些常用的手勢(shì)操作的接口,讓我們快速的處理手勢(shì)事件,比如單機(jī)、雙擊、長按、滾動(dòng)等。
通常來說我們使用 GestureDetector 分為三步:
- 初始化 GestureDetector 類。
- 定義自己的監(jiān)聽類OnGestureListener,例如實(shí)現(xiàn) GestureDetector.SimpleOnGestureListener。
- 在 dispatchTouchEvent 或 onTouchEvent 方法中,通過GestureDetector將 MotionEvent 事件交給監(jiān)聽器 OnGestureListener
例如我們最簡單的例子自定義View,控制View跟隨手指移動(dòng),我們之前的做法是手撕 onTouchEvent,在按下的時(shí)候記錄坐標(biāo),移動(dòng)的時(shí)候計(jì)算坐標(biāo),然后重繪達(dá)到View跟隨手指移動(dòng)的效果。那么此時(shí)我們就能使用另一種方式來實(shí)現(xiàn):
private GestureDetector mGestureDetector;
private float centerX;
private float centerY;
private void init(Context context) {
mGestureDetector = new GestureDetector(context, new MTouchDetector());
setClickable(true);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
//將Event事件交給監(jiān)聽器 OnGestureListener
mGestureDetector.onTouchEvent(event);
return super.dispatchTouchEvent(event);
}
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {
public boolean onDown(MotionEvent e) {
YYLogUtils.w("MTouchDetector-onDown");
return super.onDown(e);
}
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
centerY -= distanceY;
centerX -= distanceX;
//邊界處理 ...
postInvalidate();
}
}上面我們通過 GestureDetector 來實(shí)現(xiàn)了 onTouch 中的繪制效果,那么同樣的我們也可以通過 GestureDetector 來實(shí)現(xiàn) onTouch 中的時(shí)間攔截效果:
private GestureDetector mGestureDetector;
private void init(Context context) {
mGestureDetector = new GestureDetector(context, new MTouchDetector());
setClickable(true);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
// 先告訴父Viewgroup,不要攔截,然后再內(nèi)部判斷是否攔截
getParent().requestDisallowInterceptTouchEvent(true);
//將Event事件交給監(jiān)聽器 OnGestureListener
mGestureDetector.onTouchEvent(event);
return super.dispatchTouchEvent(event);
}
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {
public boolean onDown(MotionEvent e) {
YYLogUtils.w("MTouchDetector-onDown");
return super.onDown(e);
}
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (1.732 * Math.abs(distanceX) >= Math.abs(distanceY)) {
YYLogUtils.w("請(qǐng)求不要攔截我");
getParent().requestDisallowInterceptTouchEvent(true);
return true;
} else {
YYLogUtils.w("攔截我");
getParent().requestDisallowInterceptTouchEvent(false);
return false;
}
}
...
}GestureDetector 甚至能實(shí)現(xiàn) Scroller 的效果,實(shí)現(xiàn)山寨ViewPager的效果,
private GestureDetector mGestureDetector;
private void init(Context context) {
mGestureDetector = new GestureDetector(context, new MTouchDetector());
setClickable(true);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
//將Event事件交給監(jiān)聽器 OnGestureListener
mGestureDetector.onTouchEvent(event);
return super.dispatchTouchEvent(event);
}
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {
public boolean onDown(MotionEvent e) {
YYLogUtils.w("MTouchDetector-onDown");
return super.onDown(e);
}
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
//直接移動(dòng)
scrollBy((int) distanceX, getScrollY());
}
...
}可以看到我們直接在 GestureDetector 的 onScroll 回調(diào)中直接 scrollBy 有上面那種 Scroller 的效果了,比較跟手但是不能指定跳轉(zhuǎn)到頁面,但是如果想要更好的ViewPager效果,我們需要結(jié)合 Scroller 配合的使用就可以有更好的效果。
private GestureDetector mGestureDetector;
private int currentIndex;
private int startX;
private int endX;
private Scroller mScroller;
private void init(Context context) {
mGestureDetector = new GestureDetector(context, new MTouchDetector());
setClickable(true);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mGestureDetector.onTouchEvent(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
startX = (int) event.getX();
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
endX = (int) event.getX();
int tempIndex = currentIndex;
if (startX - endX > getWidth() / 2) {
tempIndex++;
} else if (endX - startX > getWidth() / 2) {
tempIndex--;
}
scrollIndex(tempIndex);
break;
}
return true;
}
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {
public boolean onDown(MotionEvent e) {
YYLogUtils.w("MTouchDetector-onDown");
return super.onDown(e);
}
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
//直接移動(dòng)
scrollBy((int) distanceX, getScrollY());
return true;
}
...
}
private void scrollIndex(int tempIndex) {
//第一頁不能滑動(dòng)
if (tempIndex < 0) {
tempIndex = 0;
}
//最后一頁不能滑動(dòng)
if (tempIndex > getChildCount() - 1) {
tempIndex = getChildCount() - 1;
}
currentIndex = tempIndex;
mScroller.startScroll(getScrollX(), 0, currentIndex * getWidth() - getScrollX(), 0);
postInvalidate();
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), 0);
postInvalidate();
}
}
這樣通過 GestureDetector 結(jié)合 Scroller 就可以達(dá)到,按著滾動(dòng)的效果和放開自動(dòng)滾動(dòng)到指定索引的效果了。
GestureDetector 確實(shí)是很方便,幫助我們封裝了事件的邏輯,我們只需要對(duì)相應(yīng)的時(shí)間做出響應(yīng)即可,我愿稱之為萬能事件處理器。
除了這些單獨(dú)的事件的處理,在同一個(gè)ViewGroup中如果有多個(gè)子View,我們還能通過 ViewDragHelper 來實(shí)現(xiàn)子 View 的自由滾動(dòng),甚至當(dāng)其中一個(gè)View滾動(dòng)的同時(shí),我可以做對(duì)應(yīng)的變化,(喲,是不是有behavior那味了)
1.3 子View的滾動(dòng)與協(xié)調(diào)交互
一句話來介紹 ViewDragHelper ,它是用于在 ViewGroup 內(nèi)部拖動(dòng)視圖的。
ViewDragHelper 也是谷歌幫我們封裝好的工具類, 其本質(zhì)就是內(nèi)部封裝了MotionEvent 和 Scroller,記錄了移動(dòng)的X和Y,讓 Scroller 去執(zhí)行滾動(dòng)邏輯,從而實(shí)現(xiàn)讓 ViewGroup 內(nèi)部的子 View 可以實(shí)滾動(dòng)與協(xié)調(diào)滾動(dòng)的邏輯。
如何使用?固定的套路:
private void initView() {
//通過回調(diào),告知告訴了移動(dòng)了多少,觸摸位置,觸摸速度
viewDragHelper = ViewDragHelper.create(this, callback);
}
/**
* 觸摸事件傳遞給ViewDragHelper
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
viewDragHelper.processTouchEvent(event);
return true; //傳遞給viewDragHelper。返回true,消費(fèi)此事件
}
/**
* 是否需要傳遞給viewDragHelper攔截事件
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean result = viewDragHelper.shouldInterceptTouchEvent(ev);
return result; //讓傳遞給viewDragHelper判斷是否需要攔截
}
//回調(diào)處理有很多,根據(jù)不同的需求來實(shí)現(xiàn)
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
@Override //是否捕獲child的觸摸事件,是否能移動(dòng)
public boolean tryCaptureView(View child, int pointerId) {
return child == redView || child == blueView; //可以移動(dòng)紅色view
}
@Override //chlid的移動(dòng)后的回調(diào),監(jiān)聽
public void onViewCaptured(View capturedChild, int activePointerId) {
super.onViewCaptured(capturedChild, activePointerId);
// Log.d("tag", "被移動(dòng)了");
}
@Override //控件水平可拖拽的范圍,目前不能限制邊界,用于手指抬起,view動(dòng)畫移動(dòng)到的位置
public int getViewHorizontalDragRange(View child) {
return getMeasuredWidth() - child.getMeasuredWidth();
}
@Override //控件垂直可拖拽的范圍,目前不能限制邊界,用于手指抬起,view動(dòng)畫移動(dòng)到的位置
public int getViewVerticalDragRange(View child) {
return getMeasuredHeight() - child.getMeasuredHeight();
}
@Override //控制水平移動(dòng)的方向。多少距離,left = child.getleft() + dx;
public int clampViewPositionHorizontal(View child, int left, int dx) {
//在這里限制最大的移動(dòng)距離,不能出邊界
if (left < 0) {
left = 0;
} else if (left > getMeasuredWidth() - child.getMeasuredWidth()) {
left = getMeasuredWidth() - child.getMeasuredWidth();
}
return left;
}
@Override //控制垂直移動(dòng)的方向。多少距離
public int clampViewPositionVertical(View child, int top, int dy) {
//在這里限制最大的移動(dòng)距離,不能出邊界
if (top < 0) {
top = 0;
} else if (top > getMeasuredHeight() - child.getMeasuredHeight()) {
top = getMeasuredHeight() - child.getMeasuredHeight();
}
return top;
}
@Override //當(dāng)前child移動(dòng)后,別的view跟著做對(duì)應(yīng)的移動(dòng)。用于做伴隨移動(dòng)
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//判斷當(dāng)藍(lán)色的移動(dòng)的時(shí)候,紅色跟著移動(dòng)相同的距離
if (changedView == blueView) {
redView.layout(redView.getLeft() + dx, redView.getTop() + dy, redView.getRight()
+ dx, redView.getBottom() + dy);
} else if (changedView == redView) {
blueView.layout(blueView.getLeft() + dx, blueView.getTop() + dy, blueView.getRight()
+ dx, blueView.getBottom() + dy);
}
}
@Override //手指抬起后,執(zhí)行相應(yīng)的邏輯
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
//以分界線判斷在左邊還是右邊
int centerLeft = getMeasuredWidth() / 2 - releasedChild.getMeasuredWidth() / 2;
if (releasedChild.getLeft() < centerLeft) {
//左邊移動(dòng)。移動(dòng)到的距離
viewDragHelper.smoothSlideViewTo(releasedChild, 0, releasedChild.getTop());
ViewCompat.postInvalidateOnAnimation(DragLayout.this); //刷新整個(gè)view
} else {
//右邊移動(dòng)。移動(dòng)到的距離
viewDragHelper.smoothSlideViewTo(releasedChild, getMeasuredWidth() -
releasedChild.getMeasuredWidth(), releasedChild.getTop());
ViewCompat.postInvalidateOnAnimation(DragLayout.this); //刷新整個(gè)view
}
}
};
@Override
public void computeScroll() {
//如果正在移動(dòng)中,繼續(xù)刷新
if (viewDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(DragLayout.this);
}
}ViewDragHelper (這名字真的取的很好),其實(shí)就是滾動(dòng)(拖拽)的幫助類,可以單獨(dú)的滾動(dòng) ViewGroup 其中的一個(gè)子View,也可以用于多個(gè)子View的協(xié)調(diào)滾動(dòng)。
這也是本期側(cè)滑菜單選用的方案,多個(gè)子View的協(xié)調(diào)滾動(dòng)的應(yīng)用。
關(guān)于更多 ViewDragHelper 的基礎(chǔ)使用,大家如果不了解可以看鴻洋的老文章【傳送門】
關(guān)于View/ViewGroup的事件,除了這些常用的之外,還有例如多指觸控事件,縮放的事件 ScaleGestureDecetor 等,由于比較少用,這里就不過多的介紹,其實(shí)邏輯與道理都是差不多的,如果有用到的話,可以再查閱對(duì)應(yīng)的文檔哦。
1.4 ViewGroup之間的嵌套與協(xié)調(diào)效果
前面講到的都是ViewGroup內(nèi)部的事件處理,關(guān)于ViewGroup之間的嵌套滾動(dòng)來說的話,其實(shí)這是另一個(gè)話題了,跟自定義ViewGroup內(nèi)部的事件處理相比,屬實(shí)是另一個(gè)分支了,演變?yōu)槎鄠€(gè)解決方案,多個(gè)知識(shí)點(diǎn)了。
我之前的文章有過簡單的介紹,目前主要是分幾種思路
- NestedScrolling機(jī)制
- CoordinatorLayout + Behavior
- CoordinatorLayout + AppBarLayout
- ConstraintLayout / MotionLayout 機(jī)制
NestedScrollingParent 與 NestedScrollingChild,NestedScrolling 機(jī)制能夠讓父view和子view在滾動(dòng)時(shí)進(jìn)行配合,其基本流程如下:當(dāng)子view開始滾動(dòng)之前,可以通知父view,讓其先于自己進(jìn)行滾動(dòng),子view滾動(dòng)之后,還可以通知父view繼續(xù)滾動(dòng)。
可以看看我之前的文章【傳送門】
由于手撕 NestedScrolling 還是有點(diǎn)難度,對(duì)于一些嵌套滾動(dòng)的需求,谷歌推出了 NestedScrollView 來實(shí)現(xiàn)嵌套滾動(dòng)。而對(duì)于一些常見的、場(chǎng)景化的協(xié)調(diào)效果來說,谷歌推出 CoordinatorLayout 封裝類,可以結(jié)合 Behavior 實(shí)現(xiàn)一些自定義的協(xié)調(diào)效果。
雖說 Behavior 的定義比 NestedScrolling 算簡單一點(diǎn)了,但是也比較復(fù)雜啊,有沒有更簡單的,對(duì)于一些更常見的場(chǎng)景,谷歌說可以結(jié)合 AppBarLayout 做出一些常見的滾動(dòng)效果。也確實(shí)解決了我們大部分滾動(dòng)效果。
關(guān)于這一點(diǎn)可以看看我之前的文章【傳送門】
雖然通過監(jiān)聽 AppBarLayout 的高度變化百分比,可以做出各種各樣的其他布局的協(xié)調(diào)動(dòng)畫效果。但是一個(gè)是效率問題,一個(gè)是難度問題,總有一些特定的效果無法實(shí)現(xiàn)。
所以谷歌推出了 ConstraintLayout / MotionLayout 能更方便的做出各種協(xié)調(diào)效果。
關(guān)于這一點(diǎn)可以看看我之前的文章【傳送門】
那么到此基本就解決了外部ViewGroup之前的嵌套與協(xié)調(diào)問題。
這里就不展開說了,這是另外一個(gè)體系,有需求的同學(xué)可以自行搜索了解一些。我們還是回歸正題。
關(guān)于自定義 ViewGroup 的事件相關(guān),我們就先初步的整理出一個(gè)目錄了,接下來我們還是快看看如何定義一個(gè)側(cè)滑菜單吧。
二、ViewDragHelper的側(cè)滑菜單實(shí)現(xiàn)
目錄列好了之后,我們就可以按需選擇或組合就可以實(shí)現(xiàn)對(duì)應(yīng)的效果。
比如我們這一期的側(cè)滑菜單,其實(shí)就是涉及到了交互與嵌套的問題,而我們通過上述的學(xué)習(xí),我們就知道我們可以有多種方式來實(shí)現(xiàn)。
- 比如手撕 onTouchEvent + Scroller(為了自動(dòng)返回)
- 再簡單點(diǎn) GestureDetector + Scroller(為了自動(dòng)返回)
- 再簡單點(diǎn) ViewDragHelper 即可(就是對(duì)Scroller的封裝)
我們這里就以最簡單的 ViewDragHelper 方案來實(shí)現(xiàn)
我們分為內(nèi)容布局和右側(cè)隱藏的刪除布局,默認(rèn)的布局方式是內(nèi)容布局占滿布局寬度,讓刪除布局到屏幕外。
首先我們要測(cè)量與布局:
private View contentView;
private View deleteView;
private int contentWidth;
private int contentHeight;
private int deleteWidth;
private int deleteHeight;
public class SwipeLayout extends FrameLayout {
//完成初始化,獲取控件
@Override
protected void onFinishInflate() {
super.onFinishInflate();
contentView = getChildAt(0);
deleteView = getChildAt(1);
}
//完成測(cè)量,獲取高度,寬度
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
contentWidth = contentView.getMeasuredWidth();
contentHeight = contentView.getMeasuredHeight();
deleteWidth = deleteView.getMeasuredWidth();
deleteHeight = deleteView.getMeasuredHeight();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
contentView.layout(0, 0, contentWidth, contentHeight);
deleteView.layout(contentView.getRight(), 0, contentView.getRight() + deleteWidth, deleteHeight);
}
}我們直接繼承 FrameLayout 也不用自行測(cè)量了,布局的時(shí)候我們布局到屏幕外的右側(cè)即可。
接下來我們就使用 viewDragHelper 來操作子View了。都是固定的寫法
private void init() {
//是否處理觸摸,是否處理攔截
viewDragHelper = ViewDragHelper.create(this, callback);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return viewDragHelper.shouldInterceptTouchEvent
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
float moveX = event.getX();
float moveY = event.getY();
float dx = moveX - downX;
float dy = moveY - downY;
if (Math.abs(dx) > Math.abs(dy)) {
//在水平移動(dòng)。請(qǐng)求父類不要攔截
requestDisallowInterceptTouchEvent(true);
}
downX = moveX;
downY = moveY;
break;
case MotionEvent.ACTION_UP:
break;
}
viewDragHelper.processTouchEvent(event);
return true;
}
注意的是這里對(duì)攔截的事件做了方向上的判斷,都是已學(xué)的內(nèi)容。接下來的重點(diǎn)就是 callback 回調(diào)的處理。
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
//點(diǎn)擊ContentView和右側(cè)的DeleteView都可以觸發(fā)事件
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == contentView || child == deleteView;
}
//控件水平可拖拽的范圍,最多也就拖出一個(gè)右側(cè)DeleteView的寬度
@Override
public int getViewHorizontalDragRange(View child) {
return deleteWidth;
}
//控制水平移動(dòng)的方向距離
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
//做邊界的限制
if (child == contentView) {
if (left > 0) left = 0;
if (left < -deleteWidth) left = -deleteWidth;
} else if (child == deleteView) {
if (left > contentWidth) left = contentWidth;
if (left < contentWidth - deleteWidth) left = contentWidth - deleteWidth;
}
return left;
}
//當(dāng)前child移動(dòng)后,別的view跟著做對(duì)應(yīng)的移動(dòng)。用于做伴隨移動(dòng)
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//做內(nèi)容布局移動(dòng)的時(shí)候,刪除布局跟著同樣的移動(dòng)
if (changedView == contentView) {
deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy,
deleteView.getRight() + dx, deleteView.getBottom() + dy);
} else if (changedView == deleteView) {
//當(dāng)刪除布局移動(dòng)的時(shí)候,內(nèi)容布局做同樣的移動(dòng)
contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy,
contentView.getRight() + dx, contentView.getBottom() + dy);
}
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
//松開之后,緩慢滑動(dòng),看是到打開狀態(tài)還是到關(guān)閉狀態(tài)
if (contentView.getLeft() < -deleteWidth / 2) {
//打開
open();
} else {
//關(guān)閉
close();
}
}
};
/**
* 打開開關(guān)的的方法
*/
public void open() {
viewDragHelper.smoothSlideViewTo(contentView, -deleteWidth, 0);
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
}
/**
* 關(guān)閉開關(guān)的方法
*/
public void close() {
viewDragHelper.smoothSlideViewTo(contentView, 0, 0);
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
}
/**
* 重寫移動(dòng)的方法
*/
@Override
public void computeScroll() {
if (viewDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
}
}已經(jīng)做了詳細(xì)的注釋了,是不是很清楚了呢? 效果圖如下:

三、回調(diào)與封裝
在一些列表上使用的時(shí)候我們需要一個(gè)Item只能打開一個(gè)刪除布局,那么我們需要一個(gè)管理類來管理,手動(dòng)的打開和關(guān)閉刪除布局。
public class SwipeLayoutManager {
private SwipeLayoutManager() {
}
private static SwipeLayoutManager mInstance = new SwipeLayoutManager();
public static SwipeLayoutManager getInstance() {
return mInstance;
}
//記錄當(dāng)前打開的item
private SwipeLayout currentSwipeLayout;
public void setSwipeLayout(SwipeLayout layout) {
this.currentSwipeLayout = layout;
}
//關(guān)閉當(dāng)前打開的item。layout
public void closeCurrentLayout() {
if (currentSwipeLayout != null) {
currentSwipeLayout.close(); //調(diào)用的自定義控件的close方法
currentSwipeLayout=null;
}
}
public boolean isShouldSwipe(SwipeLayout layout) {
if (currentSwipeLayout == null) {
//沒有打開
return true;
} else {
//有打開的
return currentSwipeLayout == layout;
}
}
//清空currentLayout
public void clearCurrentLayout() {
currentSwipeLayout = null;
}
}我們還需要對(duì)打開關(guān)閉的狀態(tài)做管理
enum SwipeState {
Open, Close;
}
private SwipeState currentState = SwipeState.Close; //默認(rèn)為關(guān)閉
如果是打開的狀態(tài),我們還需要對(duì)事件做攔截的處理
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean result = viewDragHelper.shouldInterceptTouchEvent(ev);
if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) {
//在此關(guān)閉已經(jīng)打開的item。
SwipeLayoutManager.getInstance().closeCurrentLayout();
result = true;
}
return result;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//如果當(dāng)前的是打開的,下面的邏輯不能執(zhí)行了
if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) {
requestDisallowInterceptTouchEvent(true);
return true;
}
...
}回調(diào)的處理,在 onViewPositionChanged 的移動(dòng)回調(diào)中,我們可以通過內(nèi)容布局的left是否為0 或者 -deleteWidth 就可以判斷當(dāng)前的布局狀態(tài)是否是打開狀態(tài)。
private OnSwipeStateChangeListener listener;
public void seOnSwipeStateChangeListener(OnSwipeStateChangeListener listener) {
this.listener = listener;
}
public interface OnSwipeStateChangeListener {
void Open();
void Close();
}
...
//當(dāng)前child移動(dòng)后,別的view跟著做對(duì)應(yīng)的移動(dòng)。用于做伴隨移動(dòng)
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//做內(nèi)容布局移動(dòng)的時(shí)候,刪除布局跟著同樣的移動(dòng)
if (changedView == contentView) {
deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy,
deleteView.getRight() + dx, deleteView.getBottom() + dy);
} else if (changedView == deleteView) {
//當(dāng)刪除布局移動(dòng)的時(shí)候,內(nèi)容布局做同樣的移動(dòng)
contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy,
contentView.getRight() + dx, contentView.getBottom() + dy);
}
//判斷開,關(guān)的邏輯
if (contentView.getLeft() == 0 && currentState != SwipeState.Close) {
//關(guān)閉刪除欄.刪除實(shí)例
currentState = SwipeState.Close;
if (listener != null) {
listener.Close(); //在此回調(diào)關(guān)閉方法
}
SwipeLayoutManager.getInstance().clearCurrentLayout();
} else if (contentView.getLeft() == -deleteWidth && currentState != SwipeState.Open) {
//開啟刪除欄。獲取實(shí)例
currentState = SwipeState.Open;
if (listener != null) {
listener.Open(); //在此回調(diào)打開方法
}
SwipeLayoutManager.getInstance().setSwipeLayout(SwipeLayout.this);
}
}這樣就完成了全部的邏輯啦,其實(shí)理解之后并不復(fù)雜。
后記
其實(shí)關(guān)于側(cè)滑返回的效果,網(wǎng)絡(luò)上有很多的方案,這也只是其中的一種,為了方便大家理解 viewDragHelper 的使用,其實(shí)它還可以用于很多其他的場(chǎng)景,比如底部菜單的展示,Grid網(wǎng)格的動(dòng)態(tài)變換等等。
到此這篇關(guān)于Android自定義ViewGroup實(shí)現(xiàn)側(cè)滑菜單的文章就介紹到這了,更多相關(guān)Android ViewGroup側(cè)滑菜單內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Notification消息通知 自定義消息通知內(nèi)容布局
這篇文章主要為大家詳細(xì)介紹了Notification消息通知,消息合并且顯示條數(shù),自定義消息通知內(nèi)容布局,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-09-09
Android UI設(shè)計(jì)系列之自定義DrawView組件實(shí)現(xiàn)數(shù)字簽名效果(5)
這篇文章主要介紹了Android UI設(shè)計(jì)系列之自定義DrawView組件實(shí)現(xiàn)數(shù)字簽名效果,具有一定的實(shí)用性和參考價(jià)值,感興趣的小伙伴們可以參考一下2016-06-06
Android Okhttp請(qǐng)求查詢購物車的實(shí)例代碼
下面小編就為大家分享一篇Android Okhttp請(qǐng)求查詢購物車的實(shí)例代碼,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-01-01
Android?PowerManagerService?打開省電模式
這篇文章主要介紹了Android?PowerManagerService打開省電模式,文章通告省電模式的打開過程、什么是?battery?saver?sticky?模式兩部分展開詳情,感興趣的朋友可以參考一下2022-08-08
如何使用Mock修改Android設(shè)備上的features
這篇文章主要介紹了如何使用Mock修改Android設(shè)備上的features,想了解Mock的同學(xué)可以參考下2021-04-04
Android中Glide加載圓形圖片和圓角圖片實(shí)例代碼
本篇文章主要介紹了Android中Glide加載圓形圖片和圓角圖片實(shí)例代碼,具體一定的參考價(jià)值,有興趣的可以了解一下2017-05-05
Android 中使用 ViewPager實(shí)現(xiàn)屏幕頁面切換和頁面輪播效果
ViewPager是谷歌官方給我們提供的一個(gè)兼容低版本安卓設(shè)備的軟件包,里面包囊了只有在安卓3.0以上可以使用的api。下面我們就展示下ViewPager可以實(shí)現(xiàn)的兩種簡單效果,感興趣的朋友一起看看吧2016-12-12
Android基礎(chǔ)知識(shí)之tween動(dòng)畫效果
Android基礎(chǔ)知識(shí)之tween動(dòng)畫效果,Android一共提供了兩種動(dòng)畫,這篇文章主要介紹了Android動(dòng)畫效果之tween動(dòng)畫,感興趣的小伙伴們可以參考一下2016-06-06
Flutter TV Android端開發(fā)技巧詳細(xì)教程
這篇文章主要為大家介紹了Flutter TV Android端開發(fā)技巧詳細(xì)教程,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12

