View体系(上)|青训营笔记


View体系(上)|青训营笔记

这是我参与「第四届青训营 」笔记创作活动的的第1天

View 体系是较为复杂的,但是又非常重要的一个知识点。我们把这部分知识吃透吃熟是十分必要的,打卡第一天,我把View体系的第一部分知识整理出来,快来和我一起学习吧。

View树结构

https://developer.android.com/static/images/viewgroup_2x.png?hl=zh-cn

官方给出我们使用的各种布局和各种 View 都是继承自 ViewGroup 、 View 或者他们的派生类。所以,了解View体系是极其重要的任务

如下图的 View部分继承关系 ,我们可以看到常用的 View 组件、布局组件是如何继承的。

坐标系

学习 View,首先需要知道 View 的位置在 Android 中是如何定义和测量的。

上图之中的蓝色和绿色是有着不同作用含义,我们平时使用也是在不同的地方调用

绿色:在 View 中获得 View 到其父控件之间的距离

蓝色:来自于点击事件 MotionEvent 内部的方法,可以在重写 View 事件分发体系的的三大方法的时候,利用传入的事件调用上图的蓝色方法,获取点击的位置坐标

获取坐标绘制View的滑动

//自定义一个View,点击该View可以随意滑动其位置
//下面有5个方法可以实现,其中两个由于理解为移动的是屏幕框,会使得其他元素一起偏移
public class CoutomView extends View {
    private int lastX;
    private int lastY;

    Scroller mScroller;

    public CoutomView(Context context) {
        super(context);
    }

    public CoutomView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
    }

    public CoutomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public CoutomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()){
            ((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            invalidate();
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int offsetX = x - lastX;
                int offsetY = y - lastY;
//                M1 
                layout(getLeft()+offsetX,getTop()+offsetY,
                        getRight()+offsetX,getBottom()+offsetY);

//                M2
//                offsetLeftAndRight(offsetX);
//                offsetTopAndBottom(offsetY);

//                M3
//                ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)getLayoutParams();
//                layoutParams.leftMargin = getLeft()+offsetX;
//                layoutParams.topMargin = getTop()+offsetY;
//                setLayoutParams(layoutParams);

//                M4 会使得其他元素一起偏移
//                ((View)getParent()).scrollBy(-offsetX,-offsetY);
                break;
        }

        return true;
    }

    /**
     * M5
     * 提供给Activity调用滑动,也会使得其他元素一起偏移
     * 
     * @param destX 
     * @param destY
     */
    public void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX - scrollX;
        mScroller.startScroll(scrollX,0,delta,0,2000);
        invalidate();
    }
}

属性动画

区别

View动画:只展示普通动画效果,响应的点击时间的位置依旧在原来的地方。所以无法左交互效果

属性动画:利用属性和对象来控制 View ,同时使得动画执行后可交互

属性动画的执行,可以带上属性以及属性参数

public static ObjectAnimator ofFloat(Object target, String propertyName, float... values)
    
//使用传入
ObjectAnimator.ofFloat(binding.coutomView,"translationX",0f,300f).setDuration(1000).start()

ObjectAnimator

常用属性

  • translationX 和 translantionY 沿轴平移
  • rotatian 、 rotatianX 和 rotatianY 沿着某支点进行旋转
  • PrivotX 和 PrivotY 可以控制支点位置,围绕支点旋转和缩放,支点默认为中心位置、
  • alpha 透明度,默认为1,1不透明 0 全透明
  • x,y View的终点位置

使用 ObjectAnimator 时,要调用某个属性,该属性需要有对应的 get() 和 set() 方法。若是没有,我们就需要自定义一个属性类或者包装类添加该方法

//MainActivity
val myView = MyView(binding.button)
ObjectAnimator.ofInt(myView,"width",500).setDuration(500).start()

//MyView,给MyView里面的 width 添加一个 set() 和 get() 功能
class MyView(private val mTarget: View) {
    var width: Int
        get() = mTarget.layoutParams.width
        set(width) {
            mTarget.layoutParams.width = width
            mTarget.requestLayout()
        }
}

ValueAnimator

这个方法不提供动画效果,类似数值发生器,你需要根据里面的 AnimatorUpdateListener 来监听数值,设置动画变化

//传入的值被 a.animatedValue 获取到,根据该值设置做动画
val animator = ValueAnimator.ofFloat(0f,100f).apply {
            setTarget(binding.button2)
            duration = 1000
            start()
            addUpdateListener { a ->
                val mFloat = a.animatedValue as Float
                binding.button2.rotation = mFloat
                binding.button2.translationX = 100f
            }
        }

//复杂些的动画
binding.button8.setOnClickListener {
            val anim = ValueAnimator.ofFloat(0f, 360f)
            anim.addUpdateListener { animation ->
                val angle = animation.animatedValue as Float
                binding.layer.rotation = angle
                binding.layer.scaleX = 1 + (180 - Math.abs(angle - 180)) / 20f
                binding.layer.scaleY = 1 + (180 - Math.abs(angle - 180)) / 20f


                var shift_x = 500 * Math.sin(Math.toRadians((angle * 5).toDouble())).toFloat()
                var shift_y = 500 * Math.sin(Math.toRadians((angle * 7).toDouble())).toFloat()
                binding.layer.translationX = shift_x
                binding.layer.translationY = shift_y
            }
            anim.duration = 4000
            anim.start()
        }

动画的监听

//完整的监听,四个过程
ObjectAnimator.ofFloat(binding.layer,"alpha",1.5f).addListener(object : Animator.AnimatorListener{
            override fun onAnimationStart(p0: Animator?) {
                TODO("Not yet implemented")
            }

            override fun onAnimationEnd(p0: Animator?) {
                TODO("Not yet implemented")
            }

            override fun onAnimationCancel(p0: Animator?) {
                TODO("Not yet implemented")
            }

            override fun onAnimationRepeat(p0: Animator?) {
                TODO("Not yet implemented")
            }

        })

//不完整的监听,匿名类中,重写其中的一个方法
ObjectAnimator.ofFloat(binding.layer,"alpha",0f,1f,0f,1f,0f,1f).apply {
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator?) {
                    super.onAnimationEnd(animation)
                }
            })
            duration = 10000
            start()
            }

AnimatorSet 组合动画

我们可以调用 AnimatorSet 里面的 play() 方法,以及该方法内部的类,就可以完成多个动画的组合展示功能

//eg,下面的执行顺序是 a3 -> a2 -> a1
val a1 = ObjectAnimator.ofFloat(binding.coutomView,"translationX",0f,300f,0f)
val a2 = ObjectAnimator.ofFloat(binding.coutomView,"scaleX",1.0f,2.0f)
val a3 = ObjectAnimator.ofFloat(binding.coutomView,"rotationX",0.0f,90f,0.0f)
val set = AnimatorSet().apply {
    duration = 1000
    play(a1).with(a2).after(a3)
    start()
}
//简单展示下对应方法的结构

//play()
public AnimatorSet.Builder play(Animator anim){
    if(anim != null) return new Builder(anim);
    return null;
}

//Builder 结构,对应源码可自行查看
public class Builder {
        Builder() {
            throw new RuntimeException("Stub!");
        }

        public AnimatorSet.Builder with(Animator anim) {
            throw new RuntimeException("Stub!");
        }

        public AnimatorSet.Builder before(Animator anim) {
            throw new RuntimeException("Stub!");
        }

        public AnimatorSet.Builder after(Animator anim) {
            throw new RuntimeException("Stub!");
        }

        public AnimatorSet.Builder after(long delay) {
            throw new RuntimeException("Stub!");
        }
    }

after(Animator anim) 当下 Builder 的动画放到传入的动画之后执行

after(long delay) 当下 Builder 的动画延迟指定的毫秒执行

before(Animator anim) 当下 Builder 的动画放到传入的动画之前执行

with(Animator anim) 当下 Builder 的动画与传入的动画并行执行

根据这个属性,浅析一下这段代码的逻辑。play(a1).with(a2).after(a3),

  • 首先传入 play() 的是 a1 ,会返回一个含有 a1 的Builder对象,我们简称这个对象为 b1
  • 再次调用 with() 传入 a2 ,其实就是传入 b1 的 with() 中。那当前动画就是 a1 ,传入的是 a2 ,两个并行执行。最后会返回 this 即为 b1
  • 最后再调用 after() 传入 a3,也还是 b1 内部的 after() 中。即当前动画还是 a1 ,传入的是 a3 ,a1 在 a3 后面执行。最后会返回 this 即为 b1

所以最终的顺序是 :a3 -> a1/a2

由于这几个方法都是同一个对象内的,所以当前动画 currentNode 是不变的,一直都是 a1 。那么其他需要组合的动画,都还会是以 a1 为主题,看是插入到他的前或者后。

如果有两个动画是放置与同一个位置,即 play(a1).after(a2).after(a3) 。那么 a2 和 a3 是并行执行的,即顺序为 a2 /a3 -> a1

PropertyValuesHolder 组合动画

该动画无法实现前后关系,都是并行执行的。用法如下

val valuesHolder1 = PropertyValuesHolder.ofFloat("scaleX",1.0f,1.5f)
val valuesHolder2 = PropertyValuesHolder.ofFloat("rotationX",0.0f,90.0f,0.0f)
val valuesHolder3 = PropertyValuesHolder.ofFloat("alpha",1.0f,0.3f,1.0f)
        ObjectAnimator.ofPropertyValuesHolder(binding.coutomView,valuesHolder1,valuesHolder2,valuesHolder3).apply {
        duration = 2000
        start()
 }

xml 使用属性动画

//aimator.scale.xml
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="3000"
    android:propertyName="scaleX"
    android:valueFrom="1.0"
    android:valueTo="2.0"
    android:valueType="floatType">


</objectAnimator>
AnimatorInflater.loadAnimator(this,R.animator.scale).apply {
    setTarget(binding.coutomView)
    start()
}

Scroller

graph LR
	A[startScroll] --> B[invalidate] --> C[draw] --> D[computeScroll]
public void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX - scrollX;
        mScroller.startScroll(scrollX,0,delta,0,2000);
        invalidate();
    }

@Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()){
            ((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            invalidate();
        }
    }

Scroller 并不能直接实现滑动,他最大的功能是在 startScroll() 处保存传入的滑动信息。后面再不断调用到 computeScroll() 这个方法,使用其中的 scrollTo() 来实现滑动。

computeScroll() 中,在判断方法中会调用到 mScroller.computeScrollOffset() ,这是用于获取 scrollX 和 scrollY 两个位置参数以及做出判断是否滑动结束。若是未滑动结束,就会让 computeScroll() 不断滑动重绘。

View事件分发

Activity构成图

Activity 的层级基本如上所示,在 xml 文件中构建的布局就是在 contentParent 位置,也就是 contentView 位置。

分发机制

首先需要了解的是 MotionEvent ,当屏幕被点击 ->产生点击事件 ->MotionEvent 产生。

点击事件产生后层层下发,不断传递到根 ViewGroup 。

graph LR
	A[MotionEvent] --> B[Activity]
	B --> C[PhoneWindow]
	C --> D[DecorView]
	D --> E[ViewGroup]

事件分发的三大方法

  • dispatchTouchEvent(MotionEvent event) : 用以事件分发。下面简称dTE()方法
  • onInterceptTouchEvent(MotionEvent e) : 用以拦截事件,在 dispatchTouchEvent(MotionEvent event) 中被调用来拦截。该方法只有 ViewGroup 中有, View 中没有
  • onTouchEvent(MotionEvent e) : 用以处理点击事件,在 dispatchTouchEvent(MotionEvent event) 中被调用。这个方法是 View 中的,但是由于 ViewGroup 是继承自 View 的,所以 ViewGroup 可以使用。下面简称oTE()方法

下面简述一下 dispatchTouchEvent(MotionEvent event) 方法,

//ViewGroup内
public boolean dispatchTouchEvent(MotionEvent ev){
    //拦截部分
    ...
        onInterceptTouchEvent(ev);
    ...
        
    //点击处理事件
    ...
        if(dispatchTransformedTouchEvent(ev,false,child,idBitsToAssign))
    ...
}

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,View child,int desiredPointerIdBits){
    ...
        if(child == null){
            handled = super.dispatchTouchEvent(event);
        }else{
            handled = child..dispatchTouchEvent(event);
        }
    ...
}


//View内
public boolean dispatchTouchEvent(MotionEvent ev){
    if(... && li.mOnTouchListener != null && li.mOnTouchListener.onTouch(this,event)){
        result = true;
    }
    if(!result && onTouchEvent(event)){
        result = true;
    }
}

拦截的处理逻辑

st=>start: 传入ev
req=>condition: 允许拦截?
op=>operation: 发起拦截事件
cond=>condition: 没被拦截 或 DOWN事件?
intY=>operation: 设置被拦截为 true
intN=>operation: 设置被拦截为 false
e=>end: end

st->cond->req->op
cond(yes)->req
cond(no)->intY
req(yes)->op
req(no)->intN

这其中的 允许拦截? ,一般通过子View的 requestDisallowInterceptTouchEvent 来设置,这也是处理滑动冲突的方法之一。

当事件在 ViewGroup 被拦截之后,后续的事件序列都交给其处理了

点击事件处理逻辑

st=>start: 事件已被当前VG拦截
conLoop=>condition: 子View数量C >= 0?
conView=>condition: 触点在子View内 或 子View在播动画?
op1=>operation: 执行dTTE()方法
conSC=>condition: 子View == null?
opC=>operation: 执行子View的dTE()方法
opS=>operation: 执行父View的dTE()方法
conOnTouchListener=>condition: (OTListener!=null)&&(.onTouch==true)
opRdT1=>operation: View的dTE层级result = true,表示事件被消费
opOTE=>operation: 执行oTE()方法
conUPL=>condition: 点击或长按?
opperformClick=>operation: performClick
opRoTt=>operation: OTE层 return true,表示消耗该事件
opRoTf=>operation: OTE层 return false,表示不消耗该事件
conOnClick=>condition: OnClickListener被设置?
opOnClick=>operation: 执行onClick()方法
opOnClickT=>operation: performClick层 result = true
opOnClickF=>operation: performClick层 result = false
opRdT2=>operation: View的dTE层级result = true,表示事件被消费
opRdT3=>operation: View的dTE层级result = false,事件未被消费
opSuperdTE=>operation: 父View的OTE处理
opOmit=>operation: ......
sub=>operation: C--
end1=>end: END
end2=>end: END
end3=>end: END

st->conLoop->conView->op1->conSC->opS->conOnTouchListener->opOTE->conUPL->opperformClick->conOnClick
opRoTt->opRdT2->end2

conLoop(yes)->conView
conLoop(no)->end1
conView(yes)->op1
conView(no)->sub->conLoop
conSC(yes)->opS->conOnTouchListener
conSC(no)->opC->conOnTouchListener
conOnTouchListener(yes)->opRdT1->end3
conOnTouchListener(no)->opOTE
conUPL(yes)->opperformClick->conOnClick
conUPL(no)->opRoTf->opRdT3->opSuperdTE->opOmit
conOnClick(yes)->opOnClick->opOnClickT->opRoTt
conOnClick(no)->opOnClickF->opRoTt

事件被拦截后会被当前的 ViewGroup 处理,上图就是详细的点击事件处理流程图。

注意:onTouch() 优先级大于 onClick(),事实上, View.OnClickListener()等常用点击时间都是对交互时间的二次封装

事件分发传递规则

View 的事件分发是,首先 View 层层分发下来,若是 onInterceptTouchEvent(ev) 为 true 就拦截,为 false 就继续下发。

当某一层级拦截后,就调用 onTouchEvent(event) 来处理,若是该层无法处理,就传递给父层的 onTouchEvent(event) 来处理。如此层层传递直到有对应可以处理的父层。

这是一个V型结构,先下后上。

整个事件的分发过程看起来复杂,当最终归于三大方法可以用下面的伪代码表示

public boolean dispatchTouchEvent(MotionEvent ev){
    boolean result = false;
    if(onInterceptTouchEvent(ev)){
        result = onTouchEvent(ev);
    }else{
        result = child.dispatchTouchEvent(ev);
    }
    return result;
}

问题

设置点击事件逻辑的时候,最基础的办法就是先用 findViewById() 来绑定实例,其次就是设置一个匿名内部类来监听点击,继而处理事件。那么我们可以提出以下的问题。

  1. findViewById() 如何找到并绑定对应的 View

    首先,findViewById() 会做一个层层代理,执行到 DecorView 这一层的 findViewById() 中。

    然后,DecorView 本质上是 ViewGroup,那么就变成了在 ViewGoup 上寻找对应的 View。

    由于 ViewGroup 是继承自 View 的,我们先来查看 View 的代码

    //View
    public <T extends View> T findViewById(@IdRes int id) {
        if(id == NO_ID){
            return Null;
        }
        return findViewTraversal(id);
    }
    
    public <T extends View> T findViewTraversal(@IdRes int id) {
        if(id == mID){
            return (T)this;
        }
        return null;
    }

    从上述的代码可以看出,View 中如果 id 不存在就会返回null;如果存在且等于自己,就会返回自己。

    下面我们来看 ViewGroup 中的代码

    //ViewGroup
    /**
         * {@hide}
         */
        @Override
        protected <T extends View> T findViewTraversal(@IdRes int id) {
            if (id == mID) {
                return (T) this;
            }
    
            final View[] where = mChildren;
            final int len = mChildrenCount;
    
            for (int i = 0; i < len; i++) {
                View v = where[i];
    
                if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
                    v = v.findViewById(id);
    
                    if (v != null) {
                        return (T) v;
                    }
                }
            }
    
            return null;
        }
    

    ViewGroup 是继承自 View 的,直接使用了 View 的 findViewById() 方法,所以只复写了 ViewGroup 的 findViewTraversal()。其处理逻辑是,迭代查看它的哪个子View符合,然后返回;否则返回 null。

  2. 我们常重写的 onClick() 是和 onLongClick() 他们同时设置的话会同时执行吗?执行逻辑有何不同?

    这两个方法如何执行的,我们需要查看它的源码

    //View 
    public boolean onTouchEvent(MotionEvent event) {
            ...
    
            if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
                switch (action) {
                    case MotionEvent.ACTION_UP:
                        mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                        ...
                        if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                            ...
                                if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                                // This is a tap, so remove the longpress check
                                removeLongPressCallback();//标注2
    
                                // Only perform take click actions if we were in the pressed state
                                if (!focusTaken) {
                                    // Use a Runnable and post this rather than calling
                                    // performClick directly. This lets other visual state
                                    // of the view update before click actions start.
                                    if (mPerformClick == null) {
                                        mPerformClick = new PerformClick();//标注1
                                    }
                                    if (!post(mPerformClick)) {
                                        performClickInternal();
                                    }
                                }
                            ...
                        mIgnoreNextUpEvent = false;
                        break;
    
                    case MotionEvent.ACTION_DOWN:
                        ...
    
                        if (!clickable) {
                            checkForLongClick(//标注3
                                    ViewConfiguration.getLongPressTimeout(),
                                    x,
                                    y,
                                    TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
                            break;
                        }
    
                        ...
                        break;
    
                    ...
                }
    
                return true;
            }
    
            return false;
        }

    在上文我们知道 onClick() 方法是通过上述代码标注1处来设置的,可知手指离开屏幕的时候,此处执行的是普通的点击事件。

    下面我们说下普通点击和长按点击分别如何执行。现在我们再看回标注3,此处执行的是 checkForLongClick() ,即一点击屏幕就会执行该方法,该方法会开启一个线程来计时和处理 onLongClick()。然后就看到条件为手指离开屏幕处的标注2,此处是判断此时是否已经执行了 onLongClick(),若是未执行,就说明是未能到达触发条件,此时移除不执行长按事件。移除后,就会去到标注3处,执行 onClick() 普通的点击事件。

  3. 常用的交互事件和触摸事件有哪些

    交互事件

    上文方法中的 onTouch() 和 onClick() 之类的方法的关系是前者包含后者,包括 onLongClick() 都是基于此封装的。onTouch() 是他们的入口。

    触摸事件

总结

img

以上就是 View 体系的基础内容,理解 View 的事件分发原理,是我们能化用 View 的前提。View 处理事件层层下发的思想,是非常具有借鉴学习价值的,我们代码的设计也可以借鉴这套思想,提高代码的质量。

View 事件分发的一些结论

  1. 一般一个事件序列只能被一个 View 拦截并消耗,消耗的地点在 onTouchEvent。同时如果在 onTouchEvent 做处理的话,可以将事件传递给其他 View,比如返回 false,就可以将事件传递给上层 View 做处理。

  2. 当 View 不消耗 ACTION_DOWN 的时候,那么就是 onTouchEvent 会返回 false,即将事件传递给父元素处理了。如果消耗的话,那么同一时间序列都会被处理掉。

  3. ViewGroup 的 onInterceptTouchEvent 默认返回 false。

  4. View 没有 onInterceptTouchEvent 方法,收到事件后,就会立即调用 onTouchEvent 消耗事件。

  5. View 默认消耗事件,即为 onTouchEvent 返回 true。但当它是不可点击的时候,则返回 false,即 clickable 和 longClickable 同时为 false 时候。注意 enable 属性不会对其返回值产生影响。

  6. 当 View 是可点击的,且收到 down 和 up 的事件时候,onClick 才会发生。

  7. requestDisallowInterceptTouchEvent 方法会改变 FLAG_DISALLOW_INTERCEPTER 标记位,当该 FLAG 被设置时候,就无法拦截除了 ACTION_DOWN 之外的其他事件。因为 ACTION_DOWN 会重置 FLAG_DISALLOW_INTERCEPTER 标记位,导致标记位无效。

    所以,一般是在 ACTION_DOWN 中调用 onInterceptTouchEvent 来判断是否做拦截。

  8. onInterceptTouchEvent 不是每次都会被调用的,所以最稳妥想处理事件的方式是在 dispatchTouchEvent 处理。

  9. OnTouchListener > OnTouchEvent > OnClickListener,这样放置的原因,是为了让 OnTouchListener 方便在外部调用。

参考文章

Android中View的继承关系图_Huangrong_000的博客-CSDN博客

布局 | Android 开发者 | Android Developers (google.cn)

android之View坐标系(view获取自身坐标的方法和点击事件中坐标的获取)_炸斯特的博客-CSDN博客

Activity 的组成 - 简书 (jianshu.com)

《Android进阶之光(第2版) (博文视点出品)》(刘望舒)【摘要 书评 试读】- 京东图书 (jd.com)

View.java - Android Code Search

ViewGroup.java - Android Code Search


文章作者: DYJ
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 DYJ !
评论
  目录