一、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
- LinearLayout 设置布局参数
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = 100;
layoutParams.topMargin = 100;
setLayoutParams(layoutParams);
- RelativeLayout 设置布局参数
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = 100;
layoutParams.topMargin = 100;
setLayoutParams(layoutParams);
- 它们都继承自 ViewGroup.MarginLayoutParams,所有又可以像下面这样设置
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
layoutParams.leftMargin = 100;
layoutParams.topMargin = 100;
setLayoutParams(layoutParams);
3.4、动画
- View 动画(补间动画)
注意:补间动画并不能改变 View 的真实位置
- 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>
- 代码中设置
myView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));
- 以上代码代表在 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 方法。
- 在 AT_MOST 和 EXACTLY 模式下,都返回 SpecSize 这个值,即 View 在这两种测量模式下的测量宽高直接取决于 SpecSize。也就是说,对于一个直接继承 View 的 View 来说,它的 wrap_content 和 match_parent 属性的效果是一样的。如果要实现自定义 View 的 wrap_content,就需要重写 onMeasure 方法,并对 wrap_content 属性进行处理。
- 在 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 方法。
- 如果没有设置背景,取值为 mMinwidth,mMinWidth 是可以设置的,对应于 android:minWidth 这个属性设置的值或者 View 的 setMinimumWidth 的值,如果不指定,默认为 0。
- 如果设置了背景,就取 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 方法。
- 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 方法。
- 调用 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 流程
- 如果需要,则绘制背景。
调用了 View 的 drawBackground 方法。并且有偏移量,如果不为 0,则会在偏移后的 canvas 绘制背景,之后再将偏移后的位置复原。 - 保存当前 canvas 层。
- 绘制 View 的内容。
调用 View 的 onDraw 方法,这个方法是一个空实现,因为不同的 View 有着不同的内容,需要自定义 View 自己实现。 - 绘制子 View。
调用了 dispatchDraw 方法,在 View 中这个方法也是一个空实现。在 ViewGroup 中重写了这个方法,对子类 View 进行遍历,并调用 drawChild 方法,drawChild 方法中主要调用了 View 的 draw 方法。 - 如果需要,则绘制 View 的褪色边缘,这类似于阴影效果。
- 绘制装饰,比如滚动条。
绘制装饰的方法为 View 的 onDrawForeground 方法,这个方法用于绘制 ScrollBar 以及其他装饰,并将它们绘制在视图内容的上层。
六、自定义 View
建议如果能用系统控件的情况还是应尽量用系统控件。
继承系统控件
- 这种自定义 View 在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下在 onDraw 方法中进行处理。
- 例如继承 TextView 画一条横线。
继承 View
- 不仅要实现 onDraw 方法,在实现过程中还要考虑到 wrap_content 属性以及 padding 属性的设置。
- 为了方便配置自己的自定义 View,还会对外提供自定义的属性。
- 如果要改变触控的逻辑,还要重写 onTouchEvent 等触控事件的方法。
- 例如自定义一个矩形控件。
- 对 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);
}
- 对 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);
}
}
- 自定义属性。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyView">
<attr name="view_color" format="color" />
</declare-styleable>
</resources>
- 全部代码。
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
- 首先自定义一个 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>
- 根据布局继承 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);
}
}
- 自定义属性。
<?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();
}
- 快速滑动切换页面,需要利用 VelocityTracker,在 onTouchEvent 方法的 ACTION_UP 中处理。为了用户体验,通常情况下,不仅仅要判断滑动一半时就切换页面,如果滑动速度较快,也应该判定为用户想要切换页面。参考:让控件如此丝滑Scroller和VelocityTracker的API讲解与实战——Android高级UI
@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>
- 执行效果。