💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
#### 4.4.3 自定义View示例 4.4.1节和4.4.2节分别介绍了自定义View的类别和注意事项,本节将通过几个实际的例子来演示如何自定义一个规范的View,通过本节的例子再结合上面两节的内容,可以让读者更好地掌握自定义View。下面仍然按照自定义View的分类来介绍具体的实现细节。 * 1.继承View重写onDraw方法 这种方法主要用于实现一些不规则的效果,一般需要重写onDraw方法。采用这种方式需要自己支持wrap_content,并且padding也需要自己处理。下面通过一个具体的例子来演示如何实现这种自定义View。 为了更好地展示一些平时不容易注意到的问题,这里选择实现一个很简单的自定义控件,简单到只是绘制一个圆,尽管如此,需要注意的细节还是很多的。为了实现一个规范的控件,在实现过程中必须考虑到wrap_content模式以及padding,同时为了提高便捷性,还要对外提供自定义属性。我们先来看一下最简单的实现,代码如下所示。 public class CircleView extends View { private int mColor = Color.RED; private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public CircleView(Context context) { super(context); init(); } public CircleView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mPaint.setColor(mColor); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width = getWidth(); int height = getHeight(); int radius = Math.min(width, height) / 2; canvas.drawCircle(width / 2, height / 2, radius, mPaint); } } 上面的代码实现了一个具有圆形效果的自定义View,它会在自己的中心点以宽/高的最小值为直径绘制一个红色的实心圆,它的实现很简单,并且上面的代码相信大部分初学者都能写出来,但是不得不说,上面的代码只是一种初级的实现,并不是一个规范的自定义View,为什么这么说呢?我们通过调整布局参数来对比一下。 请看下面的布局: <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:background="#ffffff" android:orientation="vertical" > <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width="match_parent" android:layout_height="100dp" android:background="#000000"/> </LinearLayout> 再看一下运行的效果,如图4-3中的(1)所示,这是我们预期的效果。接着再调整CircleView的布局参数,为其设置20dp的margin,调整后的布局如下所示。 :-: ![](https://img.kancloud.cn/88/14/881479e4efef146f41c7920cbe7e3166_1032x613.png) 图4-3 CircleView运行效果图 ``` <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width=" match_parent" android:layout_height="100dp" android:layout_margin="20dp" android:background="#000000"/> ``` 运行后看一下效果,如图4-3中的(2)所示,这也是我们预期的效果,这说明margin属性是生效的。这是因为margin属性是由父容器控制的,因此不需要在CircleView中做特殊处理。再调整CircleView的布局参数,为其设置20dp的padding,如下所示。 ``` <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width="match_parent" android:layout_height="100dp" android:layout_margin="20dp" android:padding="20dp" android:background="#000000"/> ``` 运行后看一下效果,如图4-3中的(3)所示。结果发现padding根本没有生效,这就是我们在前面提到的直接继承自View和ViewGroup的控件,padding是默认无法生效的,需要自己处理。再调整一下CircleView的布局参数,将其宽度设置为wrap_content,如下所示。 <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width="wrap_content" android:layout_height="100dp" android:layout_margin="20dp" android:padding="20dp" android:background="#000000"/> 运行后看一下效果,如图4-3中的(4)所示,结果发现wrap_content并没有达到预期的效果。对比下(3)和(4)的效果图,发现宽度使用wrap_content和使用match_parent没有任何区别。的确是这样的,这一点在前面也已经提到过:对于直接继承自View的控件,如果不对wrap_content做特殊处理,那么使用wrap_content就相当于使用match_parent。 为了解决上面提到的几种问题,我们需要做如下处理: 首先,针对wrap_content的问题,其解决方法在4.3.1节中已经做了详细的介绍,这里只需要指定一个wrap_content模式的默认宽/高即可,比如选择200px作为默认的宽/高。 其次,针对padding的问题,也很简单,只要在绘制的时候考虑一下padding即可,因此我们需要对onDraw稍微做一下修改,修改后的代码如下所示。 protected void onDraw(Canvas canvas) { super.onDraw(canvas); final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingLeft(); final int paddingTop = getPaddingLeft(); final int paddingBottom = getPaddingLeft(); int width = getWidth() - paddingLeft - paddingRight; int height = getHeight() - paddingTop - paddingBottom; int radius = Math.min(width, height) / 2; canvas.drawCircle(paddingLeft + width / 2, paddingTop + height/2, radius, mPaint); } 上面的代码很简单,中心思想就是在绘制的时候考虑到View四周的空白即可,其中圆心和半径都会考虑到View四周的padding,从而做相应的调整。 <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width="wrap_content" android:layout_height="100dp" android:layout_margin="20dp" android:padding="20dp" android:background="#000000"/> 针对上面的布局参数,我们再次运行一下,结果如图4-4中的(1)所示,可以发现布局参数中的wrap_content和padding均生效了。 :-: ![](https://img.kancloud.cn/2b/a4/2ba44ed12c4a2ad4c72fe7df8f444f58_1359x403.png) 图4-4 CircleView运行效果图 最后,为了让我们的View更加容易使用,很多情况下我们还需要为其提供自定义属性,像android:layout_width和android:padding这种以android开头的属性是系统自带的属性,那么如何添加自定义属性呢?这也不是什么难事,遵循如下几步: 第一步,在values目录下面创建自定义属性的XML,比如attrs.xml,也可以选择类似于attrs_circle_view.xml等这种以attrs_开头的文件名,当然这个文件名并没有什么限制,可以随便取名字。针对本例来说,选择创建attrs.xml文件,文件内容如下: <? xml version="1.0" encoding="utf-8"? > <resources> <declare-styleable name="CircleView"> <attr name="circle_color" format="color" /> </declare-styleable> </resources> 在上面的XML中声明了一个自定义属性集合“CircleView”,在这个集合里面可以有很多自定义属性,这里只定义了一个格式为“color”的属性“circle_color”,这里的格式color指的是颜色。除了颜色格式,自定义属性还有其他格式,比如reference是指资源id, dimension是指尺寸,而像string、integer和boolean这种是指基本数据类型。除了列举的这些还有其他类型,这里就不一一描述了,读者查看一下文档即可,这并没有什么难度。 第二步,在View的构造方法中解析自定义属性的值并做相应处理。对于本例来说,我们需要解析circle_color这个属性的值,代码如下所示。 public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable. CircleView); mColor = a.getColor(styleable.CircleView_circle_color, Color.RED); a.recycle(); init(); } 这看起来很简单,首先加载自定义属性集合CircleView,接着解析CircleView属性集合中的circle_color属性,它的id为R.styleable.CircleView_circle_color。在这一步骤中,如果在使用时没有指定circle_color这个属性,那么就会选择红色作为默认的颜色值,解析完自定义属性后,通过recycle方法来实现资源,这样CircleView中所做的工作就完成了。 第三步,在布局文件中使用自定义属性,如下所示。 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#ffffff" android:orientation="vertical" > <com.ryg.chapter_4.ui.CircleView android:id="@+id/circleView1" android:layout_width="wrap_content" android:layout_height="100dp" android:layout_margin="20dp" app:circle_color="@color/light_green" android:padding="20dp" android:background="#000000"/> </LinearLayout> 上面的布局文件中有一点需要注意,首先,为了使用自定义属性,必须在布局文件中添加schemas声明:xmlns:app=http://schemas.android.com/apk/res-auto。在这个声明中,app是自定义属性的前缀,当然可以换其他名字,但是CircleView中的自定义属性的前缀必须和这里的一致,然后就可以在CircleView中使用自定义属性了,比如:app:circle_color= "@color/light_green"。另外,也有按照如下方式声明`schemas:xmlns:app=http:// schemas.android.com/apk/res/com. ryg.chapter_4`,这种方式会在apk/res/后面附加应用的包名。但是这两种方式并没有本质区别,笔者比较喜欢的是xmlns:app=http://schemas.android.com/ apk/res-auto这种声明方式。 到这里自定义属性的使用过程就完成了,运行一下程序,效果如图4-4中的(2)所示,很显然,CircleView的自定义属性circle_color生效了。下面给出CircleView的完整代码,这时的CircleView已经是一个很规范的自定义View了,如下所示。 public class CircleView extends View { private int mColor = Color.RED; private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public CircleView(Context context) { super(context); init(); } public CircleView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable. CircleView); mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED); a.recycle(); init(); } private void init() { mPaint.setColor(mColor); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(200, 200); } else if (widthSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(200, heightSpecSize); } else if (heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, 200); } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingLeft(); final int paddingTop = getPaddingLeft(); final int paddingBottom = getPaddingLeft(); int width = getWidth() - paddingLeft - paddingRight; int height = getHeight() - paddingTop - paddingBottom; int radius = Math.min(width, height) / 2; canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint); } } * 2.继承ViewGroup派生特殊的Layout 这种方法主要用于实现自定义的布局,采用这种方式稍微复杂一些,需要合适地处理ViewGroup的测量、布局这两个过程,并同时处理子元素的测量和布局过程。在第3章的3.5.3节中,我们分析了滑动冲突的两种方式并实现了两个自定义View:HorizontalScroll-ViewEx和StickyLayout,其中HorizontalScrollViewEx就是通过继承ViewGroup来实现的自定义View,这里会再次分析它的measure和layout过程。 需要说明的是,如果要采用此种方法实现一个很规范的自定义View,是有一定的代价的,这点通过查看LinearLayout等的源码就知道,它们的实现都很复杂。对于Horizontal-ScrollViewEx来说,这里不打算实现它的方方面面,仅仅是完成主要功能,但是需要规范化的地方会给出说明。 这里再回顾一下HorizontalScrollViewEx的功能,它主要是一个类似于ViewPager的控件,也可以说是一个类似于水平方向的LinearLayout的控件,它内部的子元素可以进行水平滑动并且子元素的内部还可以进行竖直滑动,这显然是存在滑动冲突的,但是HorizontalScrollViewEx内部解决了水平和竖直方向的滑动冲突问题。关于HorizontalScrollViewEx是如何解决滑动冲突的,请参看第3章的相关内容。这里有一个假设,那就是所有子元素的宽/高都是一样的。下面主要看一下它的onMeasure和onLayout方法的实现,先看onMeasure,如下所示。 ``` protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measuredWidth = 0; int measuredHeight = 0; final int childCount = getChildCount(); measureChildren(widthMeasureSpec, heightMeasureSpec); int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if (childCount == 0) { setMeasuredDimension(0, 0); } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredWidth = childView.getMeasuredWidth() * childCount; measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(measuredWidth, measuredHeight); } else if (heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(widthSpaceSize, childView.getMeasured- Height()); } else if (widthSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredWidth = childView.getMeasuredWidth() * childCount; setMeasuredDimension(measuredWidth, heightSpaceSize); } } ``` 这里说明一下上述代码的逻辑,首先会判断是否有子元素,如果没有子元素就直接把自己的宽/高设为0;然后就是判断宽和高是不是采用了wrap_content,如果宽采用了wrap_content,那么HorizontalScrollViewEx的宽度就是所有子元素的宽度之和;如果高度采用了wrap_content,那么HorizontalScrollViewEx的高度就是第一个子元素的高度。 上述代码不太规范的地方有两点:第一点是没有子元素的时候不应该直接把宽/高设为0,而应该根据LayoutParams中的宽/高来做相应处理;第二点是在测量HorizontalScrollViewEx的宽/高时没有考虑到它的padding以及子元素的margin,因为它的padding以及子元素的margin会影响到HorizontalScrollViewEx的宽/高。这是很好理解的,因为不管是自己的padding还是子元素的margin,占用的都是HorizontalScrollViewEx的空间。 接着再看一下HorizontalScrollViewEx的onLayout方法,如下所示。 protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft = 0; final int childCount = getChildCount(); mChildrenSize = childCount; for (int i = 0; i < childCount; i++) { final View childView = getChildAt(i); if (childView.getVisibility() ! = View.GONE) { final int childWidth = childView.getMeasuredWidth(); mChildWidth = childWidth; childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight()); childLeft += childWidth; } } } 上述代码的逻辑并不复杂,其作用是完成子元素的定位。首先会遍历所有的子元素,如果这个子元素不是处于GONE这个状态,那么就通过layout方法将其放置在合适的位置上。从代码上来看,这个放置过程是由左向右的,这和水平方向的LinearLayout比较类似。上述代码的不完美之处仍然在于放置子元素的过程没有考虑到自身的padding以及子元素的margin,而从一个规范的控件的角度来看,这些都是应该考虑的。下面给出Horizontal-ScrollViewEx的完整代码,如下所示。 public class HorizontalScrollViewEx extends ViewGroup { private static final String TAG = "HorizontalScrollViewEx"; private int mChildrenSize; private int mChildWidth; private int mChildIndex; // 分别记录上次滑动的坐标 private int mLastX = 0; private int mLastY = 0; // 分别记录上次滑动的坐标(onInterceptTouchEvent) private int mLastXIntercept = 0; private int mLastYIntercept = 0; private Scroller mScroller; private VelocityTracker mVelocityTracker; public HorizontalScrollViewEx(Context context) { super(context); init(); } public HorizontalScrollViewEx(Context context, AttributeSet attrs) { super(context, attrs); init(); } public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } private void init() { if (mScroller == null) { mScroller = new Scroller(getContext()); mVelocityTracker = VelocityTracker.obtain(); } } @Override public boolean onInterceptTouchEvent(MotionEvent event) { boolean intercepted = false; int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: { intercepted = false; if (! mScroller.isFinished()) { mScroller.abortAnimation(); intercepted = true; } break; } case MotionEvent.ACTION_MOVE: { int deltaX = x - mLastXIntercept; int deltaY = y - mLastYIntercept; if (Math.abs(deltaX) > Math.abs(deltaY)) { intercepted = true; } else { intercepted = false; } break; } case MotionEvent.ACTION_UP: { intercepted = false; break; } default: break; } Log.d(TAG, "intercepted=" + intercepted); mLastX = x; mLastY = y; mLastXIntercept = x; mLastYIntercept = y; return intercepted; } @Override public boolean onTouchEvent(MotionEvent event) { mVelocityTracker.addMovement(event); int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: { if (! mScroller.isFinished()) { mScroller.abortAnimation(); } break; } case MotionEvent.ACTION_MOVE: { int deltaX = x - mLastX; int deltaY = y - mLastY; scrollBy(-deltaX, 0); break; } case MotionEvent.ACTION_UP: { int scrollX = getScrollX(); mVelocityTracker.computeCurrentVelocity(1000); float xVelocity = mVelocityTracker.getXVelocity(); if (Math.abs(xVelocity) >= 50) { mChildIndex = xVelocity > 0 ? mChildIndex -1 : mChildIndex + 1; } else { mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth; } mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize -1)); int dx = mChildIndex * mChildWidth - scrollX; smoothScrollBy(dx, 0); mVelocityTracker.clear(); break; } default: break; } mLastX = x; mLastY = y; return true; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measuredWidth = 0; int measuredHeight = 0; final int childCount = getChildCount(); measureChildren(widthMeasureSpec, heightMeasureSpec); int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if (childCount == 0) { setMeasuredDimension(0, 0); } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredWidth = childView.getMeasuredWidth() * childCount; measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(measuredWidth, measuredHeight); } else if (heightSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(widthSpaceSize, childView.getMeasured- Height()); } else if (widthSpecMode == MeasureSpec.AT_MOST) { final View childView = getChildAt(0); measuredWidth = childView.getMeasuredWidth() * childCount; setMeasuredDimension(measuredWidth, heightSpaceSize); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft = 0; final int childCount = getChildCount(); mChildrenSize = childCount; for (int i = 0; i < childCount; i++) { final View childView = getChildAt(i); if (childView.getVisibility() ! = View.GONE) { final int childWidth = childView.getMeasuredWidth(); mChildWidth = childWidth; childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight()); childLeft += childWidth; } } } private void smoothScrollBy(int dx, int dy) { mScroller.startScroll(getScrollX(), 0, dx, 0, 500); invalidate(); } @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); postInvalidate(); } } @Override protected void onDetachedFromWindow() { mVelocityTracker.recycle(); super.onDetachedFromWindow(); } } 继承特定的View(比如TextView)和继承特定的ViewGroup(比如LinearLayout)这两种方式比较简单,这里就不再举例说明了,关于第3章中提到的StickyLayout的具体实现,大家可以参看笔者在Github上的开源项目:https://github.com/singwhatiwanna/Pinned-HeaderExpandableListView。