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


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

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

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

View树结构

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

官方给出我们使用的各种布局和各种 View 都是继承自 ViewGroupView 或者他们的派生类。所以,了解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

常用属性

  • translationXtranslantionY 沿轴平移
  • rotatianrotatianXrotatianY 沿着某支点进行旋转
  • PrivotXPrivotY 可以控制支点位置,围绕支点旋转和缩放,支点默认为中心位置、
  • alpha 透明度,默认为1,1不透明 0 全透明
  • xy 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 ,会返回一个含有 a1Builder对象,我们简称这个对象为 b1
  • 再次调用 with() 传入 a2 ,其实就是传入 b1with() 中。那当前动画就是 a1 ,传入的是 a2 ,两个并行执行。最后会返回 this 即为 b1
  • 最后再调用 after() 传入 a3,也还是 b1 内部的 after() 中。即当前动画还是 a1 ,传入的是 a3a1a3 后面执行。最后会返回 this 即为 b1

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

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

如果有两个动画是放置与同一个位置,即 play(a1).after(a2).after(a3) 。那么 a2a3 是并行执行的,即顺序为 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() ,这是用于获取 scrollXscrollY 两个位置参数以及做出判断是否滑动结束。若是未滑动结束,就会让 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: OTEreturn true,表示消耗该事件
opRoTf=>operation: OTEreturn 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() 方法,所以只复写了 ViewGroupfindViewTraversal()。其处理逻辑是,迭代查看它的哪个子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. ViewGrouponInterceptTouchEvent 默认返回 false

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

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

  6. View 是可点击的,且收到 downup 的事件时候,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 !
评论
  目录