前記
android開發(fā)已經(jīng)有很久了,但是感覺自己一天天的過的很懵,技術(shù)提升慢,雖然平時(shí)做了一些筆記,但是完整的博文很少(還是缺錢了唄)。一個(gè)程序猿的職業(yè)生涯就極其短暫,如果22歲畢業(yè)開始擼代碼,還996,你還要去抽時(shí)間學(xué)習(xí),不然就是安于現(xiàn)狀,30歲就會被淘汰(我想剁了說這些話人的狗頭)。我想說不論什么時(shí)候?qū)W習(xí)都不晚!諸天氣蕩蕩,我道日興隆。
效果
OLD版本

QQ版本

android中View的繪制流程
在實(shí)現(xiàn)控件效果之前,我們先回憶一下view的繪制,它繪制肯定是依賴于它的父View,一層層繪制而來,你不能脫離與父View獨(dú)自繪制,所以它必定是從最根部的view也就是DecorView開始進(jìn)行繪制的,這里有一個(gè)很有意思的問題,因?yàn)槊總€(gè)View都需要經(jīng)歷 measure -> layout -> draw的過程,measure依賴于父View的MeasureSpec,但是DecorView沒有父View那么它的MeasureSpec從哪里來呢?
在源碼中,View的繪制是從ViewRoot的perfromTraversals()方法開始,從根ViewGroup循環(huán)繪制子View。

查看perfromTraversals()方法:
if (!mStopped || mReportNextDraw) {
boolean focusChangedDueToTouchMode = ensureTouchModeLocally(
(relayoutResult&WindowManagerGlobal.RELAYOUT_RES_IN_TOUCH_MODE) != 0);
if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth()
|| mHeight != host.getMeasuredHeight() || contentInsetsChanged ||
updatedConfiguration) {
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);//1
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);//2
if (DEBUG_LAYOUT) Log.v(mTag, "Ooops, something changed! mWidth="
+ mWidth + " measuredWidth=" + host.getMeasuredWidth()
+ " mHeight=" + mHeight
+ " measuredHeight=" + host.getMeasuredHeight()
+ " coveredInsetsChanged=" + contentInsetsChanged);
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);//3
從代碼1,2可以看到,在調(diào)用performMeasure之前進(jìn)行一次計(jì)算(getRootMeasureSpec),根據(jù)窗口尺寸和DecrorView的LayoutParams得到了Decorview的MeasureSpec。
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
//傳入的是窗口的尺寸,和當(dāng)前DecorView的LayoutParams來決定的,DecorView的LayoutParams可以在很多地方進(jìn)行改變。
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
// Window can't resize. Force root view to be windowSize.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
// Window can resize. Set max size for root view.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
// Window wants to be an exact size. Force root view to be that size.
measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
自定義View的實(shí)現(xiàn)
簡單的了解了一下View的繪制流程之后,開始手?jǐn)]一個(gè)側(cè)滑的View。
根據(jù)剛剛的側(cè)滑效果OLD版本,我們需要自定義一個(gè)ViewGroup:

內(nèi)容區(qū)域鋪滿了窗口,我們只需要通過scroll進(jìn)行滾動顯示出功能區(qū)域,不是很麻煩。
<com.example.ct.swipelayoutview.widget.SwipeLayout
android:id="@+id/swipe_layout"
android:layout_width="match_parent"
android:layout_height="89dp">
<TextView
android:id="@+id/tv_content"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/item_normal_bg"
android:text="這里是內(nèi)容區(qū)域!"
android:textColor="@android:color/black" />
<!-- 功能區(qū)開始。。。。。。。。。。。。。。。。。。。。-->
<TextView
android:id="@+id/btnTop"
android:layout_width="60dp"
android:gravity="center"
android:layout_height="match_parent"
android:background="@drawable/top_bg_normal"
android:text="置頂"
android:textColor="@android:color/white"/>
<TextView
android:id="@+id/btnUnRead"
android:layout_width="120dp"
android:gravity="center"
android:layout_height="match_parent"
android:background="@drawable/unread_bg_normal"
android:clickable="true"
android:text="標(biāo)記未讀"
android:textColor="@android:color/white"/>
<TextView
android:id="@+id/btnDelete"
android:gravity="center"
android:layout_width="60dp"
android:layout_height="match_parent"
android:background="@drawable/delete_bg_normal"
android:text="刪除"
android:textColor="@android:color/white"/>
</com.example.ct.swipelayoutview.widget.SwipeLayout>
測量布局,代碼為了簡單,都是用kotlin來實(shí)現(xiàn)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
isClickable = true//設(shè)置可點(diǎn)擊,不然無法接受到任何事件
mRightMenuWidth = 0
mHeight = 0
mDisplayWidth = 0 //內(nèi)容區(qū)域的寬度
val childCount = childCount //獲取childCount
//高度不確定不需要做測量
val measureMatchParentChildren = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY
var isNeedMeasureChildHeight = false
for (i in 0..childCount){
val childView = getChildAt(i)
if (childView!=null&&childView.visibility != View.GONE){
//設(shè)置可以點(diǎn)擊 獲取觸摸事件
childView.isClickable = true
//開始measureChildView
measureChild(childView,widthMeasureSpec,heightMeasureSpec)
val marginLayoutParams: MarginLayoutParams = childView.layoutParams as MarginLayoutParams
mHeight = max(mHeight, childView.measuredHeight)//設(shè)置高度為 子View中最高的
if(measureMatchParentChildren && marginLayoutParams.height == LayoutParams.MATCH_PARENT){
isNeedMeasureChildHeight = true
}
if(i>0){
//第一個(gè)為正常顯示的item,從第二個(gè)開始進(jìn)行計(jì)算功能區(qū)域的寬度
mRightMenuWidth += childView.measuredWidth
}else{
mContentView = childView
mDisplayWidth = childView.measuredWidth
}
}
}
//寬度設(shè)置為內(nèi)容區(qū)域的寬度
setMeasuredDimension(paddingLeft + paddingRight + mDisplayWidth,mHeight + paddingTop + paddingBottom)
mLimit = mRightMenuWidth*3/10 //百分之30為滑動臨界值,當(dāng)大于這個(gè)寬度的時(shí)候,我們需要展開功能區(qū)
mScaleTouchSlop = mRightMenuWidth*1/10 //百分之10為視為滑動,手指大于這個(gè)就判定為側(cè)滑
if(isNeedMeasureChildHeight){
//如果自身為warp_content,但是子View有match屬性的時(shí)候,需要重新測量,讓它和測量的父布局一樣高。
forceUniformHeight(widthMeasureSpec)
}
}
測量之后我們要對齊進(jìn)行布局,讓其水平布局,一個(gè)挨一個(gè)的。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
//開始布局,使用第一個(gè)View鋪滿頁面
var left = 0+ paddingLeft
for (i:Int in 0..childCount){
val childView = getChildAt(i)
if (childView!=null&&childView.visibility != GONE) {
childView.layout(left, paddingTop, left + childView.measuredWidth, paddingTop + childView.measuredHeight)
left += childView.measuredWidth
}
}
}
到此,View的繪制已經(jīng)完成了,至于onDraw方法就不用重寫了,因?yàn)槲覀儾恍枰砑宇~外的View。
自定義View的觸摸事件
View已經(jīng)繪制完畢,但是我們需要考慮幾個(gè)問題。
- 1、怎么讓功能區(qū)滑動出來?
- 2、什么時(shí)候才能點(diǎn)擊?
- 3、回彈效果怎么實(shí)現(xiàn)?
展示功能區(qū)使用的是Scroll滑動。
點(diǎn)擊情況需要簡單分為三種。(以下的情況是參照QQ實(shí)現(xiàn),如有其他情況,請自己分析一下子) -
功能區(qū)沒有展開,點(diǎn)擊應(yīng)該內(nèi)容區(qū)域。
image.png
如圖2所示:
1)事件發(fā)生在在內(nèi)容區(qū)域,如果當(dāng)前手指滑動的距離很小,然后抬起。認(rèn)為是普通點(diǎn)擊事件。
2)事件發(fā)生在內(nèi)容區(qū)域,如果當(dāng)前手指向左滑動的距離很大,觸發(fā)功能區(qū),功能區(qū)開始跟隨手指拖動,
手指放開,如果功能區(qū)滑動的距離大于臨界值就進(jìn)行展開動畫,否則就進(jìn)行回彈動畫收起功能區(qū)。
如圖3所示:
- 展開了,點(diǎn)擊功能區(qū)(點(diǎn)擊2),響應(yīng)功能區(qū)
- 展開了,點(diǎn)擊 非功能區(qū)(點(diǎn)擊1),屏蔽一切點(diǎn)擊事件,關(guān)閉功能區(qū)

簡單分析之后我們開始進(jìn)行滑動和攔截事件。
小知識
學(xué)習(xí)就是不斷的遺忘和回憶,再重新學(xué)習(xí)的過程,我們再來回憶一下View的事件分發(fā)。
三個(gè)主要函數(shù)
dispatchTouchEvent ->onInterceptTouchEvevnt -> onTouchEvent
一個(gè)完整的事件是:

所有的事件都是從activity開始分發(fā)的,直接來看ViewGroup的dispatchTouchEvent:
public boolean dispatchTouchEvent(MotionEvent ev) {
..............................
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// Check for interception.
final boolean intercepted;//是否攔截的標(biāo)志,如果不為true才會向子View分發(fā)事件,否則就自己處理
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//這個(gè)是子view設(shè)置通過requestDisallowInterceptTouchEvent(boolean flag)設(shè)置
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
}
....................
}
其中有幾個(gè)重要的Flag
- intercepted 如果為false則進(jìn)行循環(huán)調(diào)用能接受這個(gè)事件的子view的dispatchTouchEvent,否則就調(diào)用自身的onTocuhevent,如果onTouchEvent也返回false,事件就會回到Activity中去。
- mFirstTouchTarget !=null :代表的意思是當(dāng)前的這個(gè)View沒有攔截任何事件,如果有攔截down->up中任意一個(gè)事件,mFirstTouchTarget = null
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null)
代表:只有down事件(代表新的點(diǎn)擊事件)或者當(dāng)前這個(gè)view沒有攔截過任何事件的時(shí)候,才會去調(diào)用onInterceptTouchEvent,并不是每一次事件都會調(diào)用onInterceptTouchEvent!??!
- disallowIntercept :代表子view要求當(dāng)前父View不能攔截除了down事件以外的事件。意思就是子view調(diào)用requestDisallowInterceptTouchEvent(true)方法之后,down->move ->up中除了down事件,其余的事件父View都不能攔截。而且每一次down事件會重置這個(gè)flag。
- ACTION_CANCEL:什么時(shí)候觸發(fā)呢?1)父View攔截了除了down以外的事件,子view就會收到ACTION_CANCEL。2)手指滑動超過當(dāng)前View的范圍了,事件中斷,子View會收到一個(gè)ACTION_CANCEL事件。這個(gè)事件應(yīng)該和UP事件同樣的處理。
攔截事件
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//記錄速度
acquireVelocityTracker(ev)
when(ev?.action){
ACTION_DOWN ->{
//防止多根手指進(jìn)入滑動,只響應(yīng)第一根手指,否則會出現(xiàn)亂滑動的情
if (isTouching){
//如果dispatchTouchEvent 返回true代表整個(gè)事件結(jié)束了 后續(xù)事件就不傳遞了
return true
}else{
isTouching = true
}
isSwiped = false//重置滑動狀態(tài)
mLastP.set(ev.rawX,ev.rawY)//跟蹤手指坐標(biāo)
mFirstP.set(ev.rawX,ev.rawY)//手指落下的坐標(biāo)
mPointerId = ev.getPointerId(0) //獲取第一個(gè)觸點(diǎn)的坐標(biāo),用于計(jì)算滑動速度
}
ACTION_MOVE->{
val gap:Float = mLastP.x - ev.rawX
if (abs(gap) > 15 || (abs(scrollX)>0&&!isExpand)){
//如果當(dāng)前滑動距離觸發(fā)功能區(qū)了,禁止父布局?jǐn)r截事件,這樣父布局就不能滑動
parent.requestDisallowInterceptTouchEvent(true)
}
if(gap>0){
//說明向左滑動,展開
scrollBy(gap.toInt(), 0)//跟隨手指滑動
}else if(scrollX>0){
說明是向右滑動,我們只有在功能區(qū)展開的時(shí)候才去做滑動。
scrollBy(gap.toInt(), 0)//跟隨手指滑動
}
//越界修正
if(scrollX < 0){
scrollTo(0,0)
}
if (scrollX > mRightMenuWidth){
scrollTo(mRightMenuWidth,0)
}
//跟蹤坐標(biāo)
mLastP.set(ev.rawX,ev.rawY)
}
ACTION_UP, ACTION_CANCEL->{
//測量瞬間速度
mVelocityTracker?.computeCurrentVelocity(1000, mMaxVelocity.toFloat())
val velocityTrackerX = mVelocityTracker!!.getXVelocity(mPointerId)
if (abs(velocityTrackerX) > 1000){//瞬間速度視為滑動了
if(velocityTrackerX < -1000){
//使用展開動畫
smoothExpand()
}else{
//使用關(guān)閉動畫
smoothClose()
}
}else{
if(abs(scrollX)>=mLimit && !isExpand){
smoothExpand()
}else if(abs(scrollX) > 0){
if(isExpand){
//關(guān)閉所有展開View
closeAllExpland()
}else{
smoothClose()
}
}
}
isTouching = false//沒有手指觸碰我了,不然會出現(xiàn)亂滑動的情況
relaseVelocityTracker() //釋放資源
}
}
return super.dispatchTouchEvent(ev)
}
dispatchTouchEvent中的代碼都很好理解展開動畫和關(guān)閉動畫都使用了屬性動畫,不采用重寫computeScrol的方式來實(shí)現(xiàn),使用屬性動畫。
/**
* 平滑展開菜單欄
*/
private fun smoothExpand(){
if(null!= mContentView){
//展開動畫的時(shí)候,屏蔽內(nèi)容區(qū)域的長按事件
mContentView?.isLongClickable = false
}
clearAnim()//停止所有的動畫
mExpandAnim = ValueAnimator.ofInt(scrollX,mRightMenuWidth)//當(dāng)前位置滑動到功能區(qū)的最大寬度。
mExpandAnim?.addUpdateListener {
scrollTo(animation.animatedValue as Int, 0)//滑動就完事了
}
. ...........................................
mExpandAnim!!.setDuration(300).start()//開始動畫
}
如果我們當(dāng)前View在一個(gè)列表中,多個(gè)View功能區(qū)被打開之后,我們點(diǎn)擊任意非功能區(qū)的位置或者上下滑動都應(yīng)該將所有的View進(jìn)行關(guān)閉。所以我們需要一個(gè)集合來保存這些被打開的View。
val sExplands: SparseArray<SwipeLayout> = SparseArray() //記錄展開的位置(多使用SpareseArray這種類似map的集合)
//當(dāng)我們關(guān)閉的時(shí)候,需要將它從集合里面刪除掉。
到此我們的滑動效果已經(jīng)出現(xiàn)了,但是現(xiàn)在滑動的之后抬起手指就會觸發(fā)點(diǎn)擊事件。所以我們需要對這些事件進(jìn)行過濾。
/**
* 并不是每次都會調(diào)用的,一個(gè)完整的事件是從down-move....-up or cancle
* 如當(dāng)前的ViewGroup攔截除了down以外的任何一個(gè)事件,onInterceptTouchEvent都不會再調(diào)用
*/
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
when(ev?.action){
ACTION_DOWN->{
isOnIntercept = false //攔截標(biāo)志
if (scrollX>0&&ev.rawX<(mDisplayWidth - mRightMenuWidth)){
//自身View展開 ,沒有點(diǎn)擊在功能區(qū),進(jìn)行關(guān)閉 攔截點(diǎn)擊事件
closeAllExpland()
isOnIntercept = true
}else if (scrollX<=0&& sExplands.size()>0 ){
//自身view沒有展開,但是點(diǎn)擊在功能區(qū)了,進(jìn)行關(guān)閉 攔截點(diǎn)擊事件
closeAllExpland()
isOnIntercept = true
}
}
ACTION_MOVE->{
if (abs(ev.rawX - mFirstP.x)>mScaleTouchSlop){
return true //攔截事件 已經(jīng)在滑動了
}
}
ACTION_UP->{
//如功能區(qū)被打開
if ( isOnIntercept ) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}
我們只需要攔截,觸發(fā)滑動事件和功能區(qū)展開的時(shí)候點(diǎn)擊其他空白的地方。
效果已經(jīng)實(shí)現(xiàn)了。但是。。。。。。。。。。。。好像和QQ的不太一樣!
完全和QQ一樣
效果是實(shí)現(xiàn)了,但是和QQ的不太一樣,展開的時(shí)候,他是揭露式的,而不是滑動式!
原理其實(shí)并不復(fù)雜。如圖:

內(nèi)容區(qū)域覆蓋了功能區(qū)域,我們只要改變內(nèi)容區(qū)域的坐標(biāo)位置,就可以將功能區(qū)域展示出來。
怎么改變坐標(biāo)呢?使用translationX ,translationY。
@ViewDebug.ExportedProperty(category = "drawing")
public float getX() {
return mLeft + getTranslationX();
}
可以看到 x的坐標(biāo)和translationX相關(guān),只要和滑動一樣改變內(nèi)容區(qū)域translationX的位置,就可以完成揭露式效果。
到此我們已經(jīng)完成了QQ的效果,但是差別還是有的。微信的效果又是另一個(gè)方式是多層覆蓋,有興趣的同學(xué)可以觀察一下,仿照一個(gè)。
小知識
top left bottom right :view到父控件的距離
translationX 和 translationY 是 View 在相對于最初位置的偏移
scrollX scrollY 是view在滑動過程中的滾動距離
代碼Git
