android自定義view《一》仿QQ聊天側(cè)滑

前記

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

效果

OLD版本

MyVideo_1.gif

QQ版本

MyVideo_2.gif

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。


image.png

查看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:


image.png

內(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ū)
image.png

簡單分析之后我們開始進(jìn)行滑動和攔截事件。

小知識

學(xué)習(xí)就是不斷的遺忘和回憶,再重新學(xué)習(xí)的過程,我們再來回憶一下View的事件分發(fā)。
三個(gè)主要函數(shù)
dispatchTouchEvent ->onInterceptTouchEvevnt -> onTouchEvent
一個(gè)完整的事件是:


image.png

所有的事件都是從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ù)雜。如圖:


image.png

內(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容