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

前记

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容