美文网首页
android自定义view《一》仿QQ聊天侧滑

android自定义view《一》仿QQ聊天侧滑

作者: CharlesCT | 来源:发表于2019-08-18 17:55 被阅读0次

    前记

    android开发已经有很久了,但是感觉自己一天天的过的很懵,技术提升慢,虽然平时做了一些笔记,但是完整的博文很少(还是缺钱了呗)。一个程序猿的职业生涯就极其短暂,如果22岁毕业开始撸代码,还996,你还要去抽时间学习,不然就是安于现状,30岁就会被淘汰(我想剁了说这些话人的狗头)。我想说不论什么时候学习都不晚!诸天气荡荡,我道日兴隆。

    效果

    OLD版本

    MyVideo_1.gif

    QQ版本

    MyVideo_2.gif

    android中View的绘制流程

    在实现控件效果之前,我们先回忆一下view的绘制,它绘制肯定是依赖于它的父View,一层层绘制而来,你不能脱离与父View独自绘制,所以它必定是从最根部的view也就是DecorView开始进行绘制的,这里有一个很有意思的问题,因为每个View都需要经历 measure -> layout -> draw的过程,measure依赖于父View的MeasureSpec,但是DecorView没有父View那么它的MeasureSpec从哪里来呢?
    在源码中,View的绘制是从ViewRoot的perfromTraversals()方法开始,从根ViewGroup循环绘制子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可以看到,在调用performMeasure之前进行一次计算(getRootMeasureSpec),根据窗口尺寸和DecrorView的LayoutParams得到了Decorview的MeasureSpec。

    
     private static int getRootMeasureSpec(int windowSize, int rootDimension) {
            int measureSpec;
            //传入的是窗口的尺寸,和当前DecorView的LayoutParams来决定的,DecorView的LayoutParams可以在很多地方进行改变。
            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的实现

    简单的了解了一下View的绘制流程之后,开始手撸一个侧滑的View。
    根据刚刚的侧滑效果OLD版本,我们需要自定义一个ViewGroup:


    image.png

    内容区域铺满了窗口,我们只需要通过scroll进行滚动显示出功能区域,不是很麻烦。

     <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="这里是内容区域!"
                    android:textColor="@android:color/black" />
          <!-- 功能区开始。。。。。。。。。。。。。。。。。。。。-->
            <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="标记未读"
                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来实现

     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
            isClickable = true//设置可点击,不然无法接受到任何事件
            mRightMenuWidth  = 0
            mHeight = 0
            mDisplayWidth = 0 //内容区域的宽度
            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){
                    //设置可以点击 获取触摸事件
                    childView.isClickable = true
                    //开始measureChildView
                    measureChild(childView,widthMeasureSpec,heightMeasureSpec)
                    val marginLayoutParams: MarginLayoutParams  = childView.layoutParams as MarginLayoutParams
                    mHeight = max(mHeight, childView.measuredHeight)//设置高度为 子View中最高的
                    if(measureMatchParentChildren && marginLayoutParams.height == LayoutParams.MATCH_PARENT){
                        isNeedMeasureChildHeight = true
                    }
                    if(i>0){
                        //第一个为正常显示的item,从第二个开始进行计算功能区域的宽度
                        mRightMenuWidth += childView.measuredWidth
                    }else{
                        mContentView = childView
                        mDisplayWidth = childView.measuredWidth
                    }
                }
            }
            //宽度设置为内容区域的宽度
            setMeasuredDimension(paddingLeft + paddingRight + mDisplayWidth,mHeight + paddingTop + paddingBottom)
            mLimit = mRightMenuWidth*3/10 //百分之30为滑动临界值,当大于这个宽度的时候,我们需要展开功能区
            mScaleTouchSlop = mRightMenuWidth*1/10 //百分之10为视为滑动,手指大于这个就判定为侧滑
            if(isNeedMeasureChildHeight){
           //如果自身为warp_content,但是子View有match属性的时候,需要重新测量,让它和测量的父布局一样高。
                forceUniformHeight(widthMeasureSpec)
            }
        } 
    

    测量之后我们要对齐进行布局,让其水平布局,一个挨一个的。

      override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
            //开始布局,使用第一个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的绘制已经完成了,至于onDraw方法就不用重写了,因为我们不需要添加额外的View。

    自定义View的触摸事件

    View已经绘制完毕,但是我们需要考虑几个问题。

    • 1、怎么让功能区滑动出来?
    • 2、什么时候才能点击?
    • 3、回弹效果怎么实现?
      展示功能区使用的是Scroll滑动。
      点击情况需要简单分为三种。(以下的情况是参照QQ实现,如有其他情况,请自己分析一下子)
    • 功能区没有展开,点击应该内容区域。


      image.png

      如图2所示:
      1)事件发生在在内容区域,如果当前手指滑动的距离很小,然后抬起。认为是普通点击事件。
      2)事件发生在内容区域,如果当前手指向左滑动的距离很大,触发功能区,功能区开始跟随手指拖动,
      手指放开,如果功能区滑动的距离大于临界值就进行展开动画,否则就进行回弹动画收起功能区。

    如图3所示:

    • 展开了,点击功能区(点击2),响应功能区
    • 展开了,点击 非功能区(点击1),屏蔽一切点击事件,关闭功能区
    image.png

    简单分析之后我们开始进行滑动和拦截事件。

    小知识

    学习就是不断的遗忘和回忆,再重新学习的过程,我们再来回忆一下View的事件分发。
    三个主要函数
    dispatchTouchEvent ->onInterceptTouchEvevnt -> onTouchEvent
    一个完整的事件是:


    image.png

    所有的事件都是从activity开始分发的,直接来看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;//是否拦截的标志,如果不为true才会向子View分发事件,否则就自己处理
                if (actionMasked == MotionEvent.ACTION_DOWN
                        || mFirstTouchTarget != null) {
                    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//这个是子view设置通过requestDisallowInterceptTouchEvent(boolean  flag)设置
                    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;
                }
          }
          ....................
    }
    

    其中有几个重要的Flag

    • intercepted 如果为false则进行循环调用能接受这个事件的子view的dispatchTouchEvent,否则就调用自身的onTocuhevent,如果onTouchEvent也返回false,事件就会回到Activity中去。
    • mFirstTouchTarget !=null :代表的意思是当前的这个View没有拦截任何事件,如果有拦截down->up中任意一个事件,mFirstTouchTarget = null
    if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) 
    

    代表:只有down事件(代表新的点击事件)或者当前这个view没有拦截过任何事件的时候,才会去调用onInterceptTouchEvent,并不是每一次事件都会调用onInterceptTouchEvent!!!

    • disallowIntercept :代表子view要求当前父View不能拦截除了down事件以外的事件。意思就是子view调用requestDisallowInterceptTouchEvent(true)方法之后,down->move ->up中除了down事件,其余的事件父View都不能拦截。而且每一次down事件会重置这个flag。
    • ACTION_CANCEL:什么时候触发呢?1)父View拦截了除了down以外的事件,子view就会收到ACTION_CANCEL。2)手指滑动超过当前View的范围了,事件中断,子View会收到一个ACTION_CANCEL事件。这个事件应该和UP事件同样的处理。

    拦截事件

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
            //记录速度
            acquireVelocityTracker(ev)
            when(ev?.action){
                ACTION_DOWN ->{
                    //防止多根手指进入滑动,只响应第一根手指,否则会出现乱滑动的情
                    if (isTouching){
                        //如果dispatchTouchEvent 返回true代表整个事件结束了 后续事件就不传递了
                        return  true
                    }else{
                        isTouching = true
                    }
    
                    isSwiped = false//重置滑动状态
                
                    mLastP.set(ev.rawX,ev.rawY)//跟踪手指坐标
                    mFirstP.set(ev.rawX,ev.rawY)//手指落下的坐标
                    mPointerId = ev.getPointerId(0) //获取第一个触点的坐标,用于计算滑动速度
                }
                ACTION_MOVE->{
                    val gap:Float = mLastP.x - ev.rawX
                    if (abs(gap) > 15 || (abs(scrollX)>0&&!isExpand)){
                        //如果当前滑动距离触发功能区了,禁止父布局拦截事件,这样父布局就不能滑动
                        parent.requestDisallowInterceptTouchEvent(true)
                    }
                    if(gap>0){
                       //说明向左滑动,展开
                        scrollBy(gap.toInt(), 0)//跟随手指滑动
                    }else if(scrollX>0){
                        说明是向右滑动,我们只有在功能区展开的时候才去做滑动。
                        scrollBy(gap.toInt(), 0)//跟随手指滑动
                    }
    
                    //越界修正
                    if(scrollX < 0){
                        scrollTo(0,0)
                    }
                    if (scrollX > mRightMenuWidth){
                        scrollTo(mRightMenuWidth,0)
                    }
                    //跟踪坐标
                    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{
                           //使用关闭动画
                            smoothClose()
                        }
                    }else{
                        if(abs(scrollX)>=mLimit && !isExpand){
                            smoothExpand()
                        }else if(abs(scrollX) > 0){
                            if(isExpand){
                                //关闭所有展开View
                                closeAllExpland()
                            }else{
                                smoothClose()
                            }
    
                        }
                    }
                    isTouching = false//没有手指触碰我了,不然会出现乱滑动的情况
                    relaseVelocityTracker() //释放资源
                }
            }
            return super.dispatchTouchEvent(ev)
        }
    

    dispatchTouchEvent中的代码都很好理解展开动画和关闭动画都使用了属性动画,不采用重写computeScrol的方式来实现,使用属性动画。

     /**
         * 平滑展开菜单栏
         */
        private fun smoothExpand(){
            if(null!= mContentView){
                //展开动画的时候,屏蔽内容区域的长按事件
                mContentView?.isLongClickable = false
            }
            clearAnim()//停止所有的动画
            mExpandAnim = ValueAnimator.ofInt(scrollX,mRightMenuWidth)//当前位置滑动到功能区的最大宽度。
            mExpandAnim?.addUpdateListener { 
                    scrollTo(animation.animatedValue as Int, 0)//滑动就完事了
            }
         . ...........................................
            mExpandAnim!!.setDuration(300).start()//开始动画
        }
    

    如果我们当前View在一个列表中,多个View功能区被打开之后,我们点击任意非功能区的位置或者上下滑动都应该将所有的View进行关闭。所以我们需要一个集合来保存这些被打开的View。

    val sExplands: SparseArray<SwipeLayout> = SparseArray() //记录展开的位置(多使用SpareseArray这种类似map的集合)
    //当我们关闭的时候,需要将它从集合里面删除掉。
    

    到此我们的滑动效果已经出现了,但是现在滑动的之后抬起手指就会触发点击事件。所以我们需要对这些事件进行过滤。

      /**
         * 并不是每次都会调用的,一个完整的事件是从down-move....-up or cancle
         * 如当前的ViewGroup拦截除了down以外的任何一个事件,onInterceptTouchEvent都不会再调用
         */
    
        override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
            when(ev?.action){
                ACTION_DOWN->{
                    isOnIntercept = false //拦截标志
                    if (scrollX>0&&ev.rawX<(mDisplayWidth - mRightMenuWidth)){
                        //自身View展开 ,没有点击在功能区,进行关闭 拦截点击事件
                         closeAllExpland()
                        isOnIntercept = true
                    }else if (scrollX<=0&& sExplands.size()>0 ){
                        //自身view没有展开,但是点击在功能区了,进行关闭 拦截点击事件
                         closeAllExpland()
                        isOnIntercept = true
                    }
                }
                ACTION_MOVE->{
                    if (abs(ev.rawX - mFirstP.x)>mScaleTouchSlop){
                        return true //拦截事件 已经在滑动了
                    }
                }
                ACTION_UP->{
                     //如功能区被打开
                    if ( isOnIntercept ) {
                        return true
                    }
                }
            }
            return super.onInterceptTouchEvent(ev)
        }
    

    我们只需要拦截,触发滑动事件和功能区展开的时候点击其他空白的地方。
    效果已经实现了。但是。。。。。。。。。。。。好像和QQ的不太一样!

    完全和QQ一样

    效果是实现了,但是和QQ的不太一样,展开的时候,他是揭露式的,而不是滑动式!
    原理其实并不复杂。如图:


    image.png

    内容区域覆盖了功能区域,我们只要改变内容区域的坐标位置,就可以将功能区域展示出来。
    怎么改变坐标呢?使用translationX ,translationY。

     @ViewDebug.ExportedProperty(category = "drawing")
        public float getX() {
            return mLeft + getTranslationX();
        }
    

    可以看到 x的坐标和translationX相关,只要和滑动一样改变内容区域translationX的位置,就可以完成揭露式效果。
    到此我们已经完成了QQ的效果,但是差别还是有的。微信的效果又是另一个方式是多层覆盖,有兴趣的同学可以观察一下,仿照一个。

    小知识

    top left bottom right :view到父控件的距离
    translationX 和 translationY 是 View 在相对于最初位置的偏移
    scrollX scrollY 是view在滑动过程中的滚动距离
    代码Git

    相关文章

      网友评论

          本文标题:android自定义view《一》仿QQ聊天侧滑

          本文链接:https://www.haomeiwen.com/subject/nykvjctx.html