Android自绘控件要点梳理

本文所有内容来自网络,通过本人梳理编撰成文。文章仅对知识要点做罗列整理,属于知识大纲,预期读者为具备相关开发经验的研发,不建议初学者阅读。如果需要进一步探究,可以查看参考资料查看原文。

UI基础概念

坐标系

屏幕坐标系

移动设备的坐标系一般定义屏幕的左上角为坐标原点,向右为X轴增大方向,向下为Y轴增大方向。全局偏移量是指控件相对屏幕坐标系原点的偏移量。控件的屏幕全局偏移量可以通过以下方式获取:

  1. view.getLocationOnScreen(int[] location)获取相对屏幕的全局坐标
  2. view.getLocationInWindow(int[] location)获取相对窗口的全局坐标,当窗口与屏幕重合时就等于全局偏移量
  3. view.getGlobalVisibleRect(Rect rect)获取相对屏幕的全局坐标

view.getWindowVisibleDisplayFrame(rect)可获取可见view所在window的全局坐标,比如可以通过rect.top来获取状态栏高度。由于是从WMS获取,涉及IPC,注意性能。

View坐标系

控件的坐标系是相对于父控件而言的,对应局部偏移量。可以通过下述方式获取view的局部偏移量:

  1. 通过view.getLeft()/view.getRight()/view.getTop()/view.getBottom()获取四条边偏移量(float类型),此数值是在布局完成后赋值的
  2. 通过view.getX()/view.getY()获取局部x/y轴偏移量(float类型),这是实际局部偏移量
  3. 通过view.getLocalVisibleRect(Rect rect)获取局部偏移量,与left/top相对应

其中:

x = left + translationX
y = top + translationY

当我们执行view的位置偏移动画时便是通过修改translationX/translationY实现的。

事件坐标系

触摸事件的坐标提供了上述两个坐标系,即一个相对于整个手机屏幕的坐标,另一个相对于当前控件的坐标。

角度deg与弧度rad

  • 大小关系:
    360(deg) = 2π(rad)

  • 增大方向:
    ref: https://www.jianshu.com/p/682d329a4845

颜色

  • 类型
类型枚举存储格式
四通道颜色(32位)ARGB88880x00000000->0xffffffff
三通道颜色(16位)RGB8880x000000->0xffffff
  • 相关API
    • int color =Color.GRAY;
    • int color =Color.argb(127,255,0,0);
    • int color = 0xaaff0000; //0x大小写敏感,其他大小写不敏感
    • int color= Color.parseColor("#ff00ff00");
    • int color = context.getResources().getColor(R.color.mycolor);
    • xml中颜色硬编码:#00ff0000

绘制操作

视图构成

  • Activity:内部有个Window成员,它的实例为PhoneWindow;
  • PhoneWindow:有个内部类是DecorView;
  • DecorView:继承FrameLayout用于存放布局文件,里面有TitleActionBar和setContentView传进去的layout布局文件,其中id=content的就是我们传入的布局视图;

绘制流程

DecorView被加载到Window流程:

  • 从Activity的startActivity开始,最终调用到ActivityThread的handleLaunchActivity方法来创建Activity,首先,会调用performLaunchActivity方法,内部会执行Activity的onCreate方法,从而完成DecorView和Activity的创建。然后,会调用handleResumeActivity,里面首先会调用performResumeActivity去执行Activity的onResume()方法,执行完后会得到一个ActivityClientRecord对象,然后通过r.window.getDecorView()的方式得到DecorView,然后会通过a.getWindowManager()得到WindowManager,最终调用其addView()方法将DecorView加进去。
  • WindowManager的实现类是WindowManagerImpl,它内部会将addView的逻辑委托给WindowManagerGlobal,可见这里使用了接口隔离和委托模式将实现和抽象充分解耦。在WindowManagerGlobal的addView()方法中不仅会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView通过root.setView()把DecorView加载到Window中。这里的ViewRootImpl是ViewRoot的实现类,是连接WindowManager和DecorView的纽带。View的三大流程均是通过ViewRoot来完成的。

扩展阅读:

measure

MeasureSpec

MeasureSpec表示的是一个32位的整形值,它的高2位表示测量模式SpecMode,低30位表示某种测量模式下的规格大小SpecSize,它有三种测量模式:

  • EXACTLY:精确测量模式,视图宽高指定为match_parent或具体数值时生效,表示父视图已经决定了子视图的精确大小,这种模式下View的测量值就是SpecSize的值。
  • AT_MOST:最大值测量模式,当视图的宽高指定为wrap_content时生效,此时子视图的尺寸可以是不超过父视图允许的最大尺寸的任何尺寸。
  • UNSPECIFIED:不指定测量模式, 父视图没有限制子视图的大小,子视图可以是想要的任何尺寸,通常用于系统内部,应用开发中很少用到。

打包方法为makeMeasureSpec,解包方法为getMode和getSize。

普通View的MeasureSpec的创建规则

layout

未完待续…

LayoutParams

未完待续…

draw

未完待续…

Canvas

未完待续…

Paint

未完待续…

Path

未完待续…

触摸事件

事件分发机制

流程伪代码:

public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        coonsume = child.dispatchTouchEvent(ev);
    }
    return consume;
}
  • View#dispatchTouchEvent:分发事件的入口方法
    • true表示事件被当前视图消费掉;
    • false表示交给父类的onTouchEvent处理;
    • super.dispatchTouchEvent表示继续分发该事件;
  • ViewGroup#onInterceptTouchEvent:容器决绝是否自己消费还是向下传递
    • true表示拦截这个事件并交由自身的onTouchEvent方法进行消费,比如ScrollView、ListView等支持交互的容器;
    • false表示不拦截需要继续传递给子视图,如LinearLayout、 RelativeLayout等纯布局容器。
    • super.onInterceptTouchEvent(ev)根据子View情况决定:
      • 存在子View且点击到了该子View, 则不拦截继续分发给子View处理, 相当于返回false;
      • 没有子View或有子View但是没有点击中子View(此时ViewGroup 相当于普通View), 则交由该View的onTouchEvent响应,相当于返回true;
  • View#onTouchEvent:消费事件的方法
    • true表示当前视图可以处理对应的事件;
    • false表示当前视图不处理这个事件,交给父View的onTouchEvent方法进行处理;
    • super.onTouchEvent(ev)控件默认策略:
      • 如果该View是clickable或者longclickable的,则返回true表示消费了该事件,如Button等系统默认的可触摸交互的控件;
      • 如果该View非clickable或者longclickable的,则返回false表示不消费该事件,如TextView等默认不支持触摸交互的控件;

特别注意:

  1. Activity也拥有分发和消费两个方法;
  2. 事件的传递是从外向内(activity最先),而消费是从内向外(activity最后);
  3. 控件消费端三个回调的优先级:onTouchListener.onTouch > onTouchEvent > onClickListener.onClick
  4. 事件的消费是按序列来的,一旦消费了ACTION_DOWN,那么后续的ACTION_MOVE、ACTION_DOWN等事件直接交给同一个View处理;
  5. View的enable属性不影响onTouchEvent的默认返回值;
  6. requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外;

扩展阅读:

滑动冲突解决方案

  • 外部拦截法:
    指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。具体方法:需要重写父容器的onInterceptTouchEvent方法,在内部做出相应的拦截。
      @Override
      public boolean onInterceptTouchEvent(MotionEvent event) {
          boolean intercepted = false;
          switch(event.getAction()) {
              case MotionEvent.ACTION_DOWN:{
                  // 不可以拦截 ACTION_DOWN 否则后续事件都会交由父容器来处理
                  intercepted = false
                  break;
              }
              case MotionEvent.ACTION_MOVE:{
                  if(父容器需要拦截当前事件) {
                      intercept = true;
                  } else {
                      intercept = false;
                  }
                  break;
              }
              case MotionEvent.ACTION_UP:{
                  intercept = false;
                  break;
              }
              default:
              break;
          }
          return intercept;
      }
    
  • 内部拦截法:
    指父容器不拦截任何事件,而将所有的事件都传递给子容器,如果子容器需要此事件就直接消耗,否则就交由父容器进行处理。具体方法:需要配合requestDisallowInterceptTouchEvent方法。
      @Override
      public boolean dispatchTouchEvent(MotionEvent ev) {
          // 一定要在ACTION_DOWN时就要求父容器不能拦截
          if(ev.getAction() == MotionEvent.ACTION_DOWN){
              getParent().requestDisallowInterceptTouchEvent(true);
          }
          return super.dispatchTouchEvent(ev);
      }
    

推荐:当子元素占满父元素空间时使用外部拦截法,当没有占满时使用内部拦截

核心API

MotionEvent

封装了触摸事件的详细数据,包括位置、基础事件类型等信息。

GestureDetector

手势识别封装工具类,对于所有触控点击事件,我们无非就是要分析它的触控操作类型然后执行相应的逻辑,这些逻辑都是可以复用的,因此封装了GestureDetector这个工具类。
总结一下触控操作类型包括:

  1. 单击SingleTap
    时间很短:onDown ----> onSingleTapUp ----> onSingleTapConfirmed
    时间稍长:onDown ----> onShowPress ----> onSingleTapUp ----> onSingleTapConfirmed
    GestureDetector支持双击时,我们应当以onSingleTapConfirmed作为单击的回调,因为onSingleTapUp只要手指离开屏幕就会触发,意味着双击时第一次点击抬起时也会触发。
  2. 双击DoubleTap
    onDown ----> onSingleTapUp ----> onDoubleTap ----> onDoubleTapEvent ----> onDown ----> onDoubleTapEvent
    这里需要注意onDoubleTap是在第二次点击的down回调前触发,而不是第二次点击up的时候。
  3. 长按LongPress
    手指始终在屏幕上不移动:onDown ----> onShowPress ----> onLongPress
  4. 滚动Scroll
    手指始终在屏幕上发生移动:onDown ----> [onShowPress手指停留115毫秒不移动触发] ----> onScroll…
  5. 抛Filing
    手指在触摸移动过程中离开屏幕:onDown ----> onScroll… ----> onFling,理论上只要手指离开时还具有滚动速度,那么onFling就会触发,现实中屏幕操作基本是符合这个现象的,所以Scroll最后往往伴随Filing。

GestureDetector的用法分两步:

  1. 构造GestureDetector实例,需传入GestureDetector.OnGestureListener,这是处理手势事件的地方;
  2. 代理boolean onTouchEvent(MotionEvent event)boolean onTouch(View v, MotionEvent event)方法进行事件分析。

一个简单的示例如下:

public class GestureActivity extends BaseActivity {

    private static final String TAG = "GestureActivity";
    private TextView mTextView;
    private GestureDetector mDetector;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mDetector = new GestureDetector(this, getCallback());
        View content = findViewById(android.R.id.content);
        content.setFocusable(true);
        content.setClickable(true);
        content.setLongClickable(true);
        content.setOnTouchListener((v, event) -> mDetector.onTouchEvent(event));
    }
    

    private GestureDetector.OnGestureListener getCallback() {
        return new GestureDetector.SimpleOnGestureListener(){
            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                log( "onSingleTapUp() called with: e = [" + e + "]");
                return false;
            }

            @Override
            public void onLongPress(MotionEvent e) {
                super.onLongPress(e);
                log( "onLongPress() called with: e = [" + e + "]");
            }

            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                log( "onScroll() called with: e1 = [" + e1 + "], e2 = [" + e2 + "], distanceX = [" + distanceX + "], distanceY = [" + distanceY + "]");
                return false;
            }

            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                log( "onFling() called with: e1 = [" + e1 + "], e2 = [" + e2 + "], velocityX = [" + velocityX + "], velocityY = [" + velocityY + "]");
                return false;
            }

            @Override
            public void onShowPress(MotionEvent e) {
                super.onShowPress(e);
                log( "onShowPress() called with: e = [" + e + "]");
            }

            @Override
            public boolean onDown(MotionEvent e) {
                log( "onDown() called with: e = [" + e + "]");
                return false;
            }

            @Override
            public boolean onDoubleTap(MotionEvent e) {
                log( "onDoubleTap() called with: e = [" + e + "]");
                return false;
            }

            @Override
            public boolean onDoubleTapEvent(MotionEvent e) {
                log( "onDoubleTapEvent() called with: e = [" + e + "]");
                return false;
            }

            @Override
            public boolean onSingleTapConfirmed(MotionEvent e) {
                log( "onSingleTapConfirmed() called with: e = [" + e + "]");
                return false;
            }

            @Override
            public boolean onContextClick(MotionEvent e) {
                log( "onContextClick() called with: e = [" + e + "]");
                return false;
            }
        };
    }
}

ViewDragHelper

封装了控件拖拽逻辑的工具类,用在自定义ViewGroup内部快速实现子View的拖拽效果。使用方面也非常简单:

  1. 初始化ViewDragHelper,需要传入一个ViewDragHelper.Callback,这是整个拖拽逻辑的核心部分;
  2. 代理ViewGroup的onInterceptTouchEvent方法;
  3. 代理ViewGroup的onTouchEvent方法;

一个简单的示例如下:

public class DragContainer extends ConstraintLayout {
    private final ViewDragHelper helper;

    public DragContainer(Context context) {
        this(context, null);
    }

    public DragContainer(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragContainer(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        helper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            Integer rightBound;

            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                // child是被触摸的子view
                // pointerId来自MotionEvent#getPointerId(int index),区间[0, MotionEvent#getPointerCount()-1]
                return true;
            }

            @Override
            public int clampViewPositionHorizontal(View child, int left, int dx) {
                // 1. 不对横向拖拽做限制
                // return left;
                // 2. 禁用横向拖拽
                // return 0;
                // 3. 限制横向拖拽范围不超出父控件边界
                if (rightBound == null) {
                    int[] location = new int[2];
                    getLocationOnScreen(location);
                    rightBound = location[0] + getWidth() - child.getWidth();
                }
                return Math.min(Math.max(left, 0), rightBound);                
            }

            @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                return top;
            }
        });
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return helper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        helper.processTouchEvent(event);
        return true;
    }
}

然后在布局中使用就可以了,比如在下面的布局中view_crop这个控件就可以直接拖拽了:

<?xml version="1.0" encoding="utf-8"?>
<com.hao.android_api_test.view.DragContainer xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/root_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@mipmap/robot">

    <View
        android:id="@+id/view_crop"
        android:layout_width="200dp"
        android:layout_height="50dp"
        android:background="@drawable/shape_border"/>

</com.hao.android_api_test.view.DragContainer>

动画体系

未完待续…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值