View 体系和自定义 View

一、View 和 ViewGroup

  • View 是所有控件的基类,ViewGroup 继承自 View。
  • ViewGroup 是 View 的组合,可以包含多个 View 以及 ViewGroup,其包含的 ViewGroup 又可以包含 View 和 ViewGroup。

二、坐标系

在这里插入图片描述

2.1、View

  • getWidth():View 自身的宽度
  • getHeight():View 自身的高度
  • getTop():View 自身顶边到父布局的距离。
  • getLeft():View 自身左边到父布局的距离。
  • getRight():View 自身右边到父布局的距离。
  • getBottom():View 自身底边到父布局的距离。

2.2、触摸事件

最终的点击事件都由 onTouchEvent(MotionEvent event) 方法处理。只要手指触摸在屏幕,就会一直调用。重写 onTouchEvent 方法需要返回 true。

1. 常用常量

MotionEvent.ACTION_DOWN
MotionEvent.ACTION_UP
MotionEvent.ACTION_MOVE
MotionEvent.ACTION_CANCEL

2. 焦点坐标

  • getX():触摸点距离控件左边的距离,即视图坐标
  • getY():触摸点距离控件顶边的距离,即视图坐标
  • getRawX():触摸点距离整个屏幕左边的距离,即绝对坐标
  • getRawY():触摸点距离整个屏幕顶边的距离,即绝对坐标

三、View 的滑动

  • 基本思想:当点击事件传到 View 时,系统记下触摸点的坐标,手指移动时系统记下移动后触摸的坐标并算出偏移量,并通过偏移量来修改 View 的坐标。实现 View 滑动的方法有很多,这里主要讲解 6 种。
  • 屏幕原点不包含状态栏。

3.1、layout() 方法

  • 相对于父布局重新放置 View 的位置
  • 重新设置 View 的大小,但是子 View 大小不受影响,例如子 View 参数为 match_parent 时。

3.2、offsetLeftAndRight() 与 offsetTopAndBottom()

  • 相对于当前位置移动 View 的位置,左右偏移和上下偏移

3.3、LayoutParams(改变布局参数)

  • 实现向右向下各移动 100
  1. LinearLayout 设置布局参数
        LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
        layoutParams.leftMargin = 100;
        layoutParams.topMargin = 100;
        setLayoutParams(layoutParams);
  1. RelativeLayout 设置布局参数
        RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
        layoutParams.leftMargin = 100;
        layoutParams.topMargin = 100;
        setLayoutParams(layoutParams);
  1. 它们都继承自 ViewGroup.MarginLayoutParams,所有又可以像下面这样设置
        ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
        layoutParams.leftMargin = 100;
        layoutParams.topMargin = 100;
        setLayoutParams(layoutParams);

3.4、动画

  • View 动画(补间动画)
    注意:补间动画并不能改变 View 的真实位置
  1. xml 文件
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="1000"
        android:fromXDelta="0"
        android:toXDelta="300" />
</set>
  1. 代码中设置
        myView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));
  1. 以上代码代表在 1000 毫秒内向右平移 300 像素,然后回到原来的位置。不回到原来的位置,需要加上 android:fillAfter=“true” 属性。
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:fillAfter="true">
  • 属性动画(Android 3.0)
        ObjectAnimator.ofFloat(myView, "translationX", 0, 300).setDuration(1000).start();

3.5、scrollTo 与 scrollBy

  • 移动的是 View 里面的内容,View 本身不变。例如 ImageView,移动的是图片,并不是 ImageView 本身。
  • ((View) getParent()).scrollTo() 或 ((View) getParent()).scrollBy() 表示移动 View 本身,父布局不变。但是会改变 View 自身的父布局的原点坐标。这里很不好理解,比如使用 layout(0, 0, getWidth(), getHeight()) 会将 View 放置在父布局左上角,即父布局原点坐标,如果在 使用了 scrollTo 或者 scrollBy 之后再使用 layout(0, 0, getWidth(), getHeight()) ,那么父布局原点坐标会在 scrollTo 或者 scrollBy 移动后的位置。
  • scrollTo(x,y) 表示相对于原始位置移动到下一个位置坐标,scrollBy(dx,dy) 表示相对于当前位置移动多少距离。scrollBy 最终还是调用的 scrollTo。
  • 需要注意的是,关于两个方法参数的正负值,可以理解为相对于画布,这两个方法移动的是屏幕。如果 x,y 参数都为正数,屏幕向右和向下移动,那么相对于屏幕来说,里面的控件就会向左和向上移动。
    在这里插入图片描述
    在这里插入图片描述

3.6、Scroller

使用 scrollTo/scrollBy 方法进行滑动时,这个过程是瞬间完成的。 如果要实现滑动效果,需要使用 Scroller。Scroller 本身不能实现 View 的滑动,就是为了计算滑动过程的位置信息。

1. 实例化 Scroller

    private Scroller mScroller;

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

2. 重写 computeScroll() 方法
参考:Android Scroller与computeScroll方法的调用关系

  • 系统绘制 View 的时候在 draw() 方法中调用。
  • computeScrollOffset() 方法表示是否还在滑动,根据 startScroll 的 duration 参数,在此时间内 computeScrollOffset() 一直返回 false,否则返回 true。也就是说在此时间内 Scroller 会一直计算currX,currY,时间结束才会终止计算。
  • invalidate() 重绘,为了连续调用 computeScroll(),一直到滑动时间结束。
    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            ((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

3. 最后调用 startScroll(int startX, int startY, int dx, int dy, int duration)开始滑动

  • 参数说明
    startX:以 View 初始位置作为原点,动画在 x 轴上开始的位置,在初始位置向左为正,向右为负。
    startY:以 View 初始位置作为原点,动画在 y 轴上开始的位置,在初始位置向上为正,向下为负。
    dx:以 View 初始位置作为原点,动画从 startX 开始到结束间的距离。
    dy:以 View 初始位置作为原点,动画从 startY 开始到结束间的距离。
    duration:动画执行的总时间。
  • 这里调用 invalidate() 是为了第一次触发调用 computeScroll()。
 mScroller.startScroll(200, 0, -400, 0, 5000);
 invalidate();

四、View 的事件分发机制

4.1、Activity 的构成

在这里插入图片描述

  • 在源码中,DecorView 是 PhoneWindow 类的内部类,并继承了 FrameLayout,PhoneWindow 继承抽象类 Window。
  • 每个 Activity 都包含一个 Window 对象,这个对象是由 PhoneWindow 来实现的。
  • PhoneWindow 将 DecorView 作为整个应用窗口的根 View,而这个 DecorView 又将屏幕划分为两个区域,都是 FrameLayout。一个是 TitleView,另一个是 ContentView,我们平常所写的布局正是在 ContentView 中的。

4.2 、事件分发机制

由上而下

  • 点击事件用 MotionEvent 来表示,当一个点击事件产生后,事件最先传递给 Activity。
  • 当我们点击屏幕时,就产生了点击事件,这个事件被封装成了一个 MotionEvent,系统会将这个 MotionEvent 传递给 View 层级,在 View 层级中的传递过程就是点击事件分发。
  • 当点击事件产生后会由 Activity 来处理,传递给 PhoneWindow,再传递给 DecorView,最后传递给顶层的 ViewGroup。一般在事件传递中只考虑 ViewGroup 的 onInterceptTouchEvent 方法,因为一般情况下我们不会重写 dispatchTouchEvent 方法。
  • 对于根 ViewGroup,点击事件首先传递给它的 dispatchTouchEvent 方法,如果该 ViewGroup 的 onInterceptEvent 方法返回true,则表示它要拦截这个事件,这个事件就会交给它的 onTouchEvent 方法处理;如果 onInterceptTouchEvent 方法返回 false,则表示它不拦截这个事件,则这个事件会交给它的子元素的 dispatchTouchEvent 来处理,如此反复下去。
  • 如果传递给底层的 View,View 是没有子 View 的,就会调用 View 的dispatchTouchEvent 方法,一般情况下最终都会调用 View 的 onTouchEvent 方法。

由下而上

  • 当点击事件传给底层的 View 时,如果其 onTouchEvent 方法返回 true,则事件由底层的 View 消耗并处理;如果返回 false 则表示该 View 不做处理,则传递给父 View 的 onTouchEvent 处理;如果父 View 的 onTouchEvent 仍旧返回 false,则继续传递给该父 View 的父 View 处理,如此反复下去。

五、View 的工作流程

扩展知识:二进制原码反码补码1原码反码补码2位移运算符

5.1、MeasureSpec

  • MeasureSpec 是 View 的内部类,对于每一个 View 都持有一个 MeasureSpec。
  • 在测量的流程中,系统会将 View 的 LayoutParmas 根据父容器所施加的规则转换成对应的 MeasureSpec。也就是说 MeasureSpec 是受自身 LayoutParams 和父容器的 MeasureSpec 共同影响的。这都是对于普通的 View 来说,对于顶层 View 的 DecorView,它的 MeasureSpec 是由自身的 LayoutParams 和屏幕窗口的尺寸来决定的。
  • MeasureSpec 用来在 onMeasure 方法中来确定 View 的宽和高。

提供三个方法

  • 通过 makeMeasureSpec 来保存宽和高的信息,它是一个 size+mode 的合成值,通过 getSize 和 getMode 来分解。
  • 通过 getMode 得到宽或高的测量模式。
  • 通过 getSize 得到宽或高的测量大小。

三种模式

  • UNSPECIFIED:未指定模式,View 想多大就多大,父容器不做限制,一般用于系统内部的测量。
  • AT_MOST:最大模式,对应于 wrap_content 属性,控件大小一般随着控件的子空间或内容进行变化,但不超过父控件允许的最大尺寸。
  • EXACTLY:精确模式,对应于 match_parent 属性或者具体的数值,控件大小已经确定的情况。

5.2、View 的 measure 流程

measure 用来测量 View 的宽高,它的流程分为 View 的 measure 流程和 ViewGroup 的 measure 流程,只不过 ViewGroup 的 measure 流程除了要完成自己的测量,还要遍历的调用子元素的 measure 方法。

5.2.1 View 的 Measure 流程

  • 源码中首先是 onMeasure 方法,通过 setMeasuredDimension 方法设置 View 的宽高。
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }
  • setMeasuredDimension 方法。
    protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
        boolean optical = isLayoutModeOptical(this);
        if (optical != isLayoutModeOptical(mParent)) {
            Insets insets = getOpticalInsets();
            int opticalWidth  = insets.left + insets.right;
            int opticalHeight = insets.top  + insets.bottom;

            measuredWidth  += optical ? opticalWidth  : -opticalWidth;
            measuredHeight += optical ? opticalHeight : -opticalHeight;
        }
        setMeasuredDimensionRaw(measuredWidth, measuredHeight);
    }
  • getDefaultSize 方法。
  1. 在 AT_MOST 和 EXACTLY 模式下,都返回 SpecSize 这个值,即 View 在这两种测量模式下的测量宽高直接取决于 SpecSize。也就是说,对于一个直接继承 View 的 View 来说,它的 wrap_content 和 match_parent 属性的效果是一样的。如果要实现自定义 View 的 wrap_content,就需要重写 onMeasure 方法,并对 wrap_content 属性进行处理。
  2. 在 UNSPECIFIED 模式下返回的是第一个参数 size 值,size 值是从 getSuggestedMinimumWidth 和 getSuggestedMinimumHeight 方法得到的。
    public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }
  • getSuggestedMinimumWidth 方法和 getSuggestedMinimumHeight 方法。
  1. 如果没有设置背景,取值为 mMinwidth,mMinWidth 是可以设置的,对应于 android:minWidth 这个属性设置的值或者 View 的 setMinimumWidth 的值,如果不指定,默认为 0。
  2. 如果设置了背景,就取 mMinWidth 和 mBackground.getMinimumWidth() 之间的最大值,这个 mBackground 是 Drawable 类型的。
    protected int getSuggestedMinimumWidth() {
        return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
    }
    protected int getSuggestedMinimumHeight() {
        return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
    }
  • 在 Drawable 中的 getMinimumWidth 方法。
  1. intrinsicWidth 得到的是这个 Drawable 的固有宽度,如果固有宽度大于 0 则返回固有宽度,否则返回 0。
    public int getMinimumWidth() {
        final int intrinsicWidth = getIntrinsicWidth();
        return intrinsicWidth > 0 ? intrinsicWidth : 0;
    }

5.2.2 ViewGroup 的 Measure 流程

  • 对于 ViewGroup 它不仅要测量自身,还要遍历子元素的 measure 方法。
  • ViewGroup 源码中没有定义 onMeasure 方法,但却定义了 measureChildren 方法。
    protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
  • 遍历子元素并调用 measureChild 方法。
  1. 调用 child.getLayoutParams() 方法来获得子元素的 LayoutParams 属性,获取子元素的 MeasureSpec 并调用子元素的 measure 方法进行测量。
    protected void measureChild(View child, int parentWidthMeasureSpec,
            int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
  • 根据父容器的 MeasureSpec 模式再结合子元素的 LayoutParams 属性来得出的子元素的 MeasureSpec 。
  • 当父容器 MeasureSpec 属性为 AT_MOST,子元素的 LayoutParams 属性 WRAP_CONTENT,这时子元素的 MeasureSpec 属性也为 AT_MOST,这和子元素设置 LayoutParams 属性为 MATCH_PARENT 效果是一样的。为了解决这个问题,需要在 LayoutParams 属性为 WRAP_CONTENT 时指定一下默认的宽高。(有点晕)
  • 由于 ViewGroup 有不同布局的需要,很难统一,所以 ViewGroup 并没有提供 onMeasure 方法,而是让其子类来各自实现测量的方法。

5.3、View 的 layout 流程

  • Layout 方法的作用是确定元素的位置。ViewGroup 中的 layout 方法用来确定子元素的位置,View 中的 layout 方法则用来确定自身的位置。
  • layout 方法的 4 个参数 l、t、r、b 分别是 View 从左、上、右、下相对于其父容器的距离。 layout 方法中调用 setFrame 方法,根据传进来的参数确定 View 在父容器的位置。之后会调用 onLayout 方法,onLayout 方法是一个空方法,这和 onMeasure 方法类似。确定位置时根据不同的控件有不同的实现,所以在 View 和 ViewGroup 中均没有实现 onLayout 方法。

5.4、View 的 draw 流程

  1. 如果需要,则绘制背景。
    调用了 View 的 drawBackground 方法。并且有偏移量,如果不为 0,则会在偏移后的 canvas 绘制背景,之后再将偏移后的位置复原。
  2. 保存当前 canvas 层。
  3. 绘制 View 的内容。
    调用 View 的 onDraw 方法,这个方法是一个空实现,因为不同的 View 有着不同的内容,需要自定义 View 自己实现。
  4. 绘制子 View。
    调用了 dispatchDraw 方法,在 View 中这个方法也是一个空实现。在 ViewGroup 中重写了这个方法,对子类 View 进行遍历,并调用 drawChild 方法,drawChild 方法中主要调用了 View 的 draw 方法。
  5. 如果需要,则绘制 View 的褪色边缘,这类似于阴影效果。
  6. 绘制装饰,比如滚动条。
    绘制装饰的方法为 View 的 onDrawForeground 方法,这个方法用于绘制 ScrollBar 以及其他装饰,并将它们绘制在视图内容的上层。

六、自定义 View

建议如果能用系统控件的情况还是应尽量用系统控件。

继承系统控件

  • 这种自定义 View 在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下在 onDraw 方法中进行处理。
  • 例如继承 TextView 画一条横线。

在这里插入图片描述

继承 View

  • 不仅要实现 onDraw 方法,在实现过程中还要考虑到 wrap_content 属性以及 padding 属性的设置。
  • 为了方便配置自己的自定义 View,还会对外提供自定义的属性。
  • 如果要改变触控的逻辑,还要重写 onTouchEvent 等触控事件的方法。
  • 例如自定义一个矩形控件。
    在这里插入图片描述
  1. 对 padding 属性进行处理,例如画一个矩形并处理 padding 值。
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        int width = getWidth() - paddingLeft - paddingRight;
        int height = getHeight() - paddingTop - paddingBottom;
        canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint);
    }
  1. 对 warp_content 属性进行处理,当为 AT_MOST 时,设置一个默认值。
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(600, 600);
        } else if (widthMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(600, heightSpecSize);
        } else if (heightMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, 600);
        }
    }
  1. 自定义属性。
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MyView">
        <attr name="view_color" format="color" />
    </declare-styleable>
</resources>
  1. 全部代码。
public class MyView extends View {
    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public MyView(Context context) {
        super(context);
        initDraw();
    }

    public MyView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyView);
        mColor = typedArray.getColor(R.styleable.MyView_view_color, Color.RED);
        typedArray.recycle();
        initDraw();
    }

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

    private void initDraw() {
        mPaint.setColor(mColor);
        mPaint.setStrokeWidth(1.5f);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(600, 600);
        } else if (widthMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(600, heightSpecSize);
        } else if (heightMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, 600);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        int width = getWidth() - paddingLeft - paddingRight;
        int height = getHeight() - paddingTop - paddingBottom;
        canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint);
    }

继承系统特定的 ViewGroup

  • 例如自定义一个简单的 TitleBar

在这里插入图片描述

  1. 首先自定义一个 RelativeLayout 的布局。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/titlebarRootLayout"
    android:layout_width="match_parent"
    android:layout_height="45dp"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/ivTitlebarLeft"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentStart="true"
        android:paddingLeft="15dp"
        android:paddingRight="15dp"
        android:src="@mipmap/ic_launcher" />

    <TextView
        android:id="@+id/tvTitlebarTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:ellipsize="end"
        android:maxEms="8"
        android:maxLines="1" />

    <ImageView
        android:id="@+id/ivTitlebarRight"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentEnd="true"
        android:paddingLeft="15dp"
        android:paddingRight="15dp"
        android:src="@mipmap/ic_launcher" />
</RelativeLayout>
  1. 根据布局继承 RelativeLayout 。
public class TitleBar extends RelativeLayout {
    private RelativeLayout titlebarRootLayout;
    private TextView tvTitlebarTitle;
    private ImageView ivTitlebarLeft, ivTitlebarRight;

    private int mColor;
    private int mTextColor;
    private String mTitle;

    public TitleBar(Context context) {
        super(context);
        initView(context);
    }

    public TitleBar(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TitleBar);
        mColor = typedArray.getColor(R.styleable.TitleBar_title_bg, Color.BLUE);
        mTextColor = typedArray.getColor(R.styleable.TitleBar_title_text_color, Color.RED);
        mTitle = typedArray.getString(R.styleable.TitleBar_title_text);
        typedArray.recycle();
        initView(context);
    }

    public TitleBar(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView(context);
    }

    private void initView(Context context) {
        LayoutInflater.from(context).inflate(R.layout.view_customtitle, this, true);
        titlebarRootLayout = findViewById(R.id.titlebarRootLayout);
        tvTitlebarTitle = findViewById(R.id.tvTitlebarTitle);
        ivTitlebarLeft = findViewById(R.id.ivTitlebarLeft);
        ivTitlebarRight = findViewById(R.id.ivTitlebarRight);
        titlebarRootLayout.setBackgroundColor(mColor);
        tvTitlebarTitle.setTextColor(mTextColor);
        tvTitlebarTitle.setText(mTitle);
    }

    public void setTitle(String title) {
        if (!TextUtils.isEmpty(title)) {
            tvTitlebarTitle.setText(title);
        }
    }

    public void setLeftListener(OnClickListener onClickListener) {
        ivTitlebarLeft.setOnClickListener(onClickListener);
    }

    public void setRightListener(OnClickListener onClickListener) {
        ivTitlebarRight.setOnClickListener(onClickListener);
    }
}
  1. 自定义属性。
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TitleBar">
        <attr name="title_text_color" format="color" />
        <attr name="title_bg" format="color" />
        <attr name="title_text" format="string" />
    </declare-styleable>
</resources>

继承 ViewGroup

  • 首先 onMeasure 方法中测量,正常情况下,我们应该根据 LayoutParams 中的宽高来处理,接着根据 widthMode 和 heightMode 来分别设置宽高,这里采用简化写法,如果没有子元素,直接将宽高设置为 0。这里没有考虑 padding 和子元素的 margin。
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        // 如果没有子元素,就设置宽和高都为 0
        if (getChildCount() == 0) {
            setMeasuredDimension(0, 0);
        }
        // 宽和高都是 AT_MOST,则宽度设置为所有子元素宽度的和,高度设置为第一个子元素的高度
        else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            int childHeight = childOne.getMeasuredHeight();
            setMeasuredDimension(childWidth * getChildCount(), childHeight);
        }
        // 宽度是 AT_MOST,则宽度为所有子元素宽度的和
        else if (widthMode == MeasureSpec.AT_MOST) {
            int childWidth = getChildAt(0).getMeasuredWidth();
            setMeasuredDimension(childWidth * getChildCount(), heightSize);
        }
        // 高度是 AT_MOST,则高度为第一个子元素的高度
        else if (heightMode == MeasureSpec.AT_MOST) {
            int childHeight = getChildAt(0).getMeasuredHeight();
            setMeasuredDimension(widthSize, childHeight);
        }
    }
  • 实现 onLayout 方法布局子元素。遍历所有子元素,如果子元素不是 GONE,则调用子元素的 layout 方法将其放置到合适的位置上。在这里第一个子元素占满位置,后面的子元素以相同的宽度依次想后排,对于 left 来说是一直累加的。这里没有考虑 padding 和子元素的 margin。
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left = 0;
        View child;
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                int width = child.getMeasuredWidth();
                childWidth = width;
                child.layout(left, 0, left + width, child.getMeasuredHeight());
                left += width;
            }
        }
    }
  • 滑动切换页面,需要利用到 Scroller ,在 onTouchEvent 方法中处理。
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int distance = getScrollX() - currentIndex * childWidth;
                // 判断滑动距离是否大于 1/2,是就切换页面
                if (Math.abs(distance) > childWidth / 2) {
                    if (distance > 0) {
                        currentIndex++;
                    } else {
                        currentIndex--;
                    }
                }
                smoothScrollTo(currentIndex * childWidth, 0);
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }
    
    private void smoothScrollTo(int destX, int destY) {
        scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
        invalidate();
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 为 VelocityTracker 传入触摸事件(包括ACTION_DOWN、ACTION_MOVE、ACTION_UP等),
        // 这样 VelocityTracker 才能在调用了 computeCurrentVelocity 方法后,正确的获得当前的速度。
        tracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int distance = getScrollX() - currentIndex * childWidth;
                // 判断滑动距离是否大于 1/2,是就切换页面
                if (Math.abs(distance) > childWidth / 2) {
                    if (distance > 0) {
                        currentIndex++;
                    } else {
                        currentIndex--;
                    }
                } else {
                    // 根据已经传入的触摸事件计算出当前的速度,可以通过getXVelocity 或 getYVelocity进行获取对应方向上的速度。
                    // 值得注意的是,计算出的速度值不超过Float.MAX_VALUE。参数解析: 速度的单位。值为1表示每毫秒像素数,1000表示每秒像素数。
                    tracker.computeCurrentVelocity(1000);
                    float xV = tracker.getXVelocity();
                    if (Math.abs(xV) > 50) {
                        if (xV > 0) {
                            // 切换到上一个页面
                            currentIndex--;
                        } else {
                            // 切换到下一个页面
                            currentIndex++;
                        }
                    }
                }
                currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
                smoothScrollTo(currentIndex * childWidth, 0);
                // 重置 VelocityTracker 回其初始状态。
                tracker.clear();
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }
  • 处理滑动冲突,如果里面是垂直滑动的 RecyclerView,这时候会导致滑动冲突。在滑动的时候检测到滑动方向是水平的话,就让父 View 拦截,确保父 View 用来进行 View 的滑动切换。
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastInterceptX;
                int deltaY = y - lastInterceptY;
                // 判断是水平滑动还是垂直滑动
                if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
                    intercept = true;
                    Log.d("TAG", "intercept=true");
                } else {
                    intercept = false;
                    Log.d("TAG", "intercept=false");
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
        }
        // 如果不拦截,将不会执行 onTouchEvent 方法,会直接进入到子元素的点击事件,所以在这里也要设置 lastX 和 lastY。
        lastX = x;
        lastY = y;
        lastInterceptX = x;
        lastInterceptY = y;
        return intercept;
    }
  • 再次触摸屏幕阻止页面继续滑动。
            case MotionEvent.ACTION_DOWN:
                if (!scroller.isFinished()) {
                    scroller.abortAnimation();
                }
                break;
  • 完整代码。
public class HorizontalView extends ViewGroup {
    private int lastX;
    private int lastY;
    private int currentIndex = 0;
    private int childWidth = 0;
    private Scroller scroller;
    private VelocityTracker tracker;
    private int lastInterceptX = 0;
    private int lastInterceptY = 0;

    public HorizontalView(Context context) {
        super(context);
        init();
    }

    public HorizontalView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void init() {
        scroller = new Scroller(getContext());
        tracker = VelocityTracker.obtain();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        // 如果没有子元素,就设置宽和高都为 0
        if (getChildCount() == 0) {
            setMeasuredDimension(0, 0);
        }
        // 宽和高都是 AT_MOST,则宽度设置为所有子元素宽度的和,高度设置为第一个子元素的高度
        else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            int childHeight = childOne.getMeasuredHeight();
            setMeasuredDimension(childWidth * getChildCount(), childHeight);
        }
        // 宽度是 AT_MOST,则宽度为所有子元素宽度的和
        else if (widthMode == MeasureSpec.AT_MOST) {
            int childWidth = getChildAt(0).getMeasuredWidth();
            setMeasuredDimension(childWidth * getChildCount(), heightSize);
        }
        // 高度是 AT_MOST,则高度为第一个子元素的高度
        else if (heightMode == MeasureSpec.AT_MOST) {
            int childHeight = getChildAt(0).getMeasuredHeight();
            setMeasuredDimension(widthSize, childHeight);
        }
    }


    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left = 0;
        View child;
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                int width = child.getMeasuredWidth();
                childWidth = width;
                child.layout(left, 0, left + width, child.getMeasuredHeight());
                left += width;
            }
        }
    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                if (!scroller.isFinished()) {
                    scroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastInterceptX;
                int deltaY = y - lastInterceptY;
                // 判断是水平滑动还是垂直滑动
                if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
                    intercept = true;
                    Log.d("TAG", "intercept=true");
                } else {
                    intercept = false;
                    Log.d("TAG", "intercept=false");
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
        }
         // 如果不拦截,将不会执行 onTouchEvent 方法,会直接进入到子元素的点击事件,所以在这里也要设置 lastX 和 lastY。
        lastX = x;
        lastY = y;
        lastInterceptX = x;
        lastInterceptY = y;
        return intercept;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 为 VelocityTracker 传入触摸事件(包括ACTION_DOWN、ACTION_MOVE、ACTION_UP等),
        // 这样 VelocityTracker 才能在调用了 computeCurrentVelocity 方法后,正确的获得当前的速度。
        tracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!scroller.isFinished()) {
                    scroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int distance = getScrollX() - currentIndex * childWidth;
                // 判断滑动距离是否大于 1/2,是就切换页面
                if (Math.abs(distance) > childWidth / 2) {
                    if (distance > 0) {
                        currentIndex++;
                    } else {
                        currentIndex--;
                    }
                } else {
                    // 根据已经传入的触摸事件计算出当前的速度,可以通过getXVelocity 或 getYVelocity进行获取对应方向上的速度。
                    // 值得注意的是,计算出的速度值不超过Float.MAX_VALUE。参数解析: 速度的单位。值为1表示每毫秒像素数,1000表示每秒像素数。
                    tracker.computeCurrentVelocity(1000);
                    // 获取速度值,如果速度的绝对值大于 50,则认为是“快速滑动”
                    float xV = tracker.getXVelocity();
                    if (Math.abs(xV) > 50) {
                        if (xV > 0) {
                            // 切换到上一个页面
                            currentIndex--;
                        } else {
                            // 切换到下一个页面
                            currentIndex++;
                        }
                    }
                }
                currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
                smoothScrollTo(currentIndex * childWidth, 0);
                // 重置 VelocityTracker 回其初始状态。
                tracker.clear();
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }

    private void smoothScrollTo(int destX, int destY) {
        scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
        invalidate();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            postInvalidate();
        }
    }
}
  • 布局文件。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.mryuan.learndemo.HorizontalView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/green">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView1"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/colorPrimary" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView2"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/green" />
    </com.mryuan.learndemo.HorizontalView>

</LinearLayout>
  • 执行效果。
    在这里插入图片描述
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值