View 体系和自定义 View

it2023-07-04  71

文章目录

一、View 和 ViewGroup二、坐标系2.1、View2.2、触摸事件 三、View 的滑动3.1、layout() 方法3.2、offsetLeftAndRight() 与 offsetTopAndBottom()3.3、LayoutParams(改变布局参数)3.4、[动画](https://editor.csdn.net/md?articleId=109218073)3.5、scrollTo 与 scrollBy3.6、Scroller 四、View 的事件分发机制4.1、Activity 的构成4.2 、事件分发机制 五、View 的工作流程5.1、MeasureSpec5.2、View 的 measure 流程5.3、View 的 layout 流程5.4、View 的 draw 流程 六、自定义 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 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> 执行效果。
最新回复(0)