赞
踩
有时候我们会想要实现一些复杂或者是独特的组件效果,这时候系统提供的组件可能不能满足我们的需求,这个时候我们一般就会有两个解决办法,一是上网查找开源的控件库,一些流行的开源库可以满足我们的绝大部分需求。不过这些开源库也具有时效性,因为它们的更新得不到保证。二就是我们自己实现我们需要的控件,开源库本质也是自定义View。
不过实现自定义View需要我们对View的事件分发以及View的工作流程有一定了解,可以查看之前的博客:
本篇文章将会主要介绍如何初步实现自定义View。
本篇文章涉及到的内容主要有:
在正式进入自定义View的内容之前我们有必要简单说明一下View的构造方法,这对我们初始化一些参数具有作用。View的构造方法主要是有四种重载:
public View(Context context):
这个构造方法通常在代码中使用,用于在给定的上下文(Context)中创建一个新的View实例。它是最常用的构造方法之一,用于在代码中动态创建View。这个构造方法在实例化View时使用,但是在View被添加到父容器之前,需要设置视图的属性和布局参数。
public View(Context context, AttributeSet attrs):
这个构造方法通常在布局文件中使用,用于在XML布局文件中定义的View元素被实例化时调用。在XML布局文件中,每个View元素都对应一个View实例,这个构造方法用于实例化这些View。
public View(Context context, AttributeSet attrs, int defStyleAttr):
这个构造方法通常在主题(Theme)中使用,用于在指定的主题样式(Theme Style)中创建一个新的View实例。它允许通过主题样式为View设置默认的属性和样式。
public View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes):
这个构造方法通常在主题(Theme)中使用,用于在指定的主题样式(Theme Style)中创建一个新的View实例,并指定一个默认样式资源。它与上一个构造方法类似,但是可以指定一个默认样式资源,用于在主题样式中未指定的情况下为View设置属性和样式。
当我们依次点开View构造方法的源码时,就会发现他们之间是存在调用关系的:
public View(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { this(context); ...... } public View(Context context) { mContext = context; mResources = context != null ? context.getResources() : null; ..... }
可以看到这个调用关系还是比较清晰的,显然有两个参数的构造方法是我们正常使用xml实例化时的构造入口,调用顺序如下:
因此,在需要对一些参数进行初始化时(比如说Scroller等),可以将其放到第三个方法中,然后构造方法依次进行调用即可,具体在后面的例子中会进行演示。
我们要实现自定义View的话,也可以将其进行一定的分类,主要可以分为以下三种:
自定义View
1)继承系统控件:一般用于拓展某种已有的View的功能
2)继承View:主要用于实现一些不规则的效果,需要重写onDraw;还需要自己支持wrap_content属性和padding属性。
自定义ViewGroup
1)继承系统控件:当某种效果看起来很像几种View组合在一起的时候;这种方法不需要自己处理测量和布局这两个过程。
2)继承ViewGroup:主要用于实现自己的布局,需要合适地处理ViewGroup的测量和布局两个过程。
自定义组合控件
自定义View又可以分为继承View的和继承系统控件的(比如TextView);同理,自定义ViewGroup也可以分为继承ViewGroup和继承系统控件的(比如滑动冲突文章里写的HorzontalScrollViewEx就是继承了LinearLayout);而自定义组合控件又是什么呢?可以理解为由多个控件组合而成的控件,将其视作一个整体。
接下来我们继承View实现一个自定义View,这个自定义View的显示很简单,就是绘制一个圆形,那在这里就需要提前了解两个比较关键,分别是Paint类和Canvas类,我一般会将前者Paint理解为画笔,它用于描述绘制的样式、颜色和效果等属性。而Canvas类可以理解为画布,它提供了一系列的绘制方法,用于在其中绘制图形、文本和位图等内容。通过 Canvas 类,我们可以实现对 View 或其他绘制目标的绘制操作。这两个方法具体会在onDraw方法绘制中用到。
我们先给出自定义圆的源码:
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); this(context,attrs,0); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs,defStyleAttr,0); //init(); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(); //init(); } private void init(){ mPaint.setColor(mColor); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //获得了测量的尺寸和模式 int widSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int widSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widResultSize = 0; int heightResultSize = 0; //这里我们如果想实现标准模式,可以参考别的View的测量流程 if(widSpecMode == MeasureSpec.AT_MOST){ widResultSize = 0; }else if(widSpecMode == MeasureSpec.EXACTLY){ widResultSize = widSpecSize; }else{ super.onMeasure(widthMeasureSpec,heightMeasureSpec); } if(heightSpecMode == MeasureSpec.AT_MOST){ heightResultSize = 0; }else if(widSpecMode == MeasureSpec.EXACTLY){ heightResultSize = heightSpecSize; }else{ super.onMeasure(widthMeasureSpec,heightMeasureSpec); } setMeasuredDimension(widResultSize,heightResultSize); } ..... }
直接先从构造方法中看起来,可以看到我们遵守了View类中的初始化流程,初始化init方法放到最后一个构造方法中调用,但是其他的构造方法最终会调用到最后一个构造方法。
这个类中还声明了两个私有成员变量,分别是mColor和mPaint,mColor是用来设置画出圆的颜色的,而mPaint就是我们之前提到的画笔,它的构造方法中传入的是一些标志位,用来指定绘制出来的效果的,以下是常用的标志位和它们的含义:
接着再init初始化方法中,主要就是设置了画笔的颜色,这点很简单,我们往后看。根据View的工作流程来说,是需要经历onMeasure到onLayout最后再到onDraw的。之前的文章分析中,我们也知道默认情况下设置View的长和宽为wrap_content属性是无效的,所以需要重写onMeasure方法。在新的onMeasure方法中,我们做的就是根据自身的测量模式和测量尺寸来设置最终的测量尺寸。具体做的操作就是当属性设置为wrap_content时,将其大小设置为0,这样当我们设置属性为wrap_content时就有效果了。
由于这是View不是ViewGroup,所以我们可以不用管onLayout方法。
接下来看比较重要,也是实现圆的绘制的onDraw方法方法:
@Override
protected void onDraw(Canvas canvas) { //padding属性:内间距--属于该View的自身边界内,所以需要自己处理
super.onDraw(canvas);
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddingBottom = getPaddingBottom();
int width = getWidth()-paddingRight-paddingLeft ;
if (width < 0) width = 0;
int height = getHeight()-paddingTop-paddingBottom;
if(height < 0) height = 0;
int radius = Math.min(width,height)/2;
canvas.drawCircle(getWidth()/2,getHeight()/2,radius,mPaint);
}
最重要的就是最后一行的drawCircle方法,这是调用了canvas类的方法,看名字也知道这个方法就是用来绘制圆形的,传入的四个参数分别是圆心的X值,圆心的Y值,半径值,用来绘制圆形的画笔。上面也提到过了,要我们自己处理padding属性,这里也将padding属性考虑进去了,这样当我们在布局文件中设置padding属性时就有用了,最终实现的效果:
接下来我们升级一下这个自定义圆,让他可以设置自定义参数并绘制一个双色的同心圆。自定义参数就是我们自己定义的属性,这个属性可以影响最终绘制出来的效果,就和padding等参数一样。
创建自定义参数的做法也很简单,首先我们在values文件夹中新建一个xml文件用来存储我们自己的自定义属性,然后在里面定义两个自定义属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleView">
<attr name="circle_color" format="color" />
<attr name="circle_outer_color" format="color"/>
</declare-styleable>
</resources>
两个declare-styleable 标签后面跟的name就是我们写的自定义View的类,标签之前的参数就是我们自己定义的参数,这里我们定义了两个参数,都是代表颜色属性的,第一个代表同心圆内部的颜色,第二个代表同心圆外部的颜色。接下来我们在自定义View中使用这些参数,给出修改完后的自定义View的完整代码:
public class CircleView extends View { private int mColor = Color.RED;//取红色 private int outerColor = Color.BLUE; private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//应该是画笔 private Paint mOuterPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public CircleView(Context context) { super(context); //init(); } public CircleView(Context context, AttributeSet attrs) { //super(context, attrs); this(context,attrs,0); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs,defStyleAttr,0); //init(); } public CircleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);//获取CircleView下所有的自定义属性 mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED); outerColor = a.getColor(R.styleable.CircleView_circle_outer_color,Color.BLUE); a.recycle(); init(); //init(); } private void init(){ mPaint.setColor(mColor); mOuterPaint.setColor(outerColor); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //获得了测量的尺寸和模式 int widSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int widSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widResultSize = 0; int heightResultSize = 0; //这里我们如果想实现标准模式,可以参考别的View的测量流程 if(widSpecMode == MeasureSpec.AT_MOST){ widResultSize = 0; }else if(widSpecMode == MeasureSpec.EXACTLY){ widResultSize = widSpecSize; }else{ super.onMeasure(widthMeasureSpec,heightMeasureSpec); } if(heightSpecMode == MeasureSpec.AT_MOST){ heightResultSize = 0; }else if(widSpecMode == MeasureSpec.EXACTLY){ heightResultSize = heightSpecSize; }else{ super.onMeasure(widthMeasureSpec,heightMeasureSpec); } setMeasuredDimension(widResultSize,heightResultSize); } @Override protected void onDraw(Canvas canvas) { //padding属性:内间距--属于该View的自身边界内,所以需要自己处理 super.onDraw(canvas); final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int paddingTop = getPaddingTop(); final int paddingBottom = getPaddingBottom(); int width = getWidth()-paddingRight-paddingLeft ; if (width < 0) width = 0; int height = getHeight()-paddingTop-paddingBottom; if(height < 0) height = 0; int radius = Math.min(width,height)/2; canvas.drawCircle(getWidth()/2,getHeight()/2,radius,mOuterPaint);//在画布上用mPaint画笔画一个圆心在中间,半径为r的圆 canvas.drawCircle(getWidth()/2,getHeight()/2,(int)(radius*0.75),mPaint); } }
我们看第四个构造参数中是怎么取出自定义View的属性的,首先是通过TypedArray 属性数组取出自定义属性,具体是调用
context的obtainStyledAttributes函数,传入的第二个参数就是我们写的标签的name,这样就取出了自定义属性,最后赋值给成员变量。getColor方法中的第二个参数就是默认参数(即当自定义属性没有设置时的默认值)。最后使用完TypedArray后还要将其回收。
至于如何画出一个同心圆,也很简单,我们可以先画一个大一点的圆,然后再画一个小一点的圆,当小圆遮挡住大圆的中心部分时,就达到了一个双色同心圆的效果:
canvas.drawCircle(getWidth()/2,getHeight()/2,radius,mOuterPaint);//在画布上用mPaint画笔画一个圆心在中间,半径为r的圆
canvas.drawCircle(getWidth()/2,getHeight()/2,(int)(radius*0.75),mPaint);
最后看看效果:
这里我们也总结出一些在自定义View时需要注意的事项:
让View支持wrap_content
因为直接继承了View或者ViewGroup的控件,如果不在onMeasure中对wrap_content做特殊处理,那么这个属性默认是不起作用的。
如果有必要,让View支持Padding
padding属性也和上面的wrap_content属性类似,如果我们自己不对padding属性进行处理,那么它默认也是无效的;另外,直接继承ViewGroup的控件需要在onMeasure方法和onLayout方法中考虑padding和子元素的margin属性,不然也会导致这两个属性设置了也失效。
尽量不要在View中使用Handler
因为View本身就有post方法,所以说没必要用Handler
View中如果有线程或者动画,需要及时停止,参考View#onDetachedFromWindow
如果有线程或者动画需要停止时,onDetachedFromWindow方法是一个很好的时机,它会在包含次View的Activity退出时和当前View被移除时被调用;与之对应的方法是onAttachedToWindow。
View带有滑动嵌套情形时,需要处理好滑动冲突
这个情况我们在滑动冲突的文章里已经探讨过了,具体可以看之前的博客。
这里我们根据我们之前的HorzontalScrollViewEx进行改造,不同的是这里我们不是继承LinearLayout而是ViewGroup,这样需要我们自己实现onMeasure和onLayout方法,这里直接给出全部代码:
public class HorizontalScrollViewEx extends ViewGroup { private int mChildrenSize; private int mChildWidth; private int mChildIndex; private int mLastX = 0; private int mLastY = 0; private int mLastInterceptX = 0; private int mLastInterceptY = 0; private Scroller mScroller; private VelocityTracker mVelocityTracker; private static final String TAG = "HZT"; //自己写的伪ViewPager public HorizontalScrollViewEx(Context context) { super(context); } public HorizontalScrollViewEx(Context context, AttributeSet attrs) { this(context,attrs,0); } public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr) { this(context,attrs,defStyleAttr,0); } public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(); } private void init(){ mScroller = new Scroller(getContext()); mVelocityTracker = VelocityTracker.obtain(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int measuredWidth = 0; int measuredHeight = 0; final int childCount = getChildCount();//获得子View的数量 measureChildren(widthMeasureSpec,heightMeasureSpec);//先测出子View int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if(childCount == 0){ setMeasuredDimension(0,0);//如果没有子View的话,就直接设置大小为零 }else if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){ final View childView = getChildAt(0);//获得第一个子View的实例 measuredWidth = childView.getMeasuredWidth() * childCount;//伪ViewPager measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(measuredWidth,measuredHeight); }else if(heightSpecMode == MeasureSpec.AT_MOST){ final View childView = getChildAt(0); measuredHeight = childView.getMeasuredHeight(); setMeasuredDimension(widthSpecSize,measuredHeight); }else if(widthSpecMode == MeasureSpec.AT_MOST){ final View childView = getChildAt(0); measuredWidth = childView.getMeasuredWidth() * childCount; setMeasuredDimension(measuredWidth,heightSpecSize); } } @Override //测量完毕之后开始放置 -- 从左向右放置 protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft = 0; final int childCount = getChildCount(); mChildrenSize = childCount; Log.d(TAG, "Count: "+mChildrenSize); 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;//依次向右放置 } } } //开始处理 滑动冲突 @Override public boolean onInterceptTouchEvent(MotionEvent ev) { int x = (int) ev.getX(); int y = (int) ev.getY(); boolean intercepted = false; switch (ev.getAction()){ case ACTION_DOWN:{ intercepted = false; if(!mScroller.isFinished()){ mScroller.abortAnimation(); intercepted = true; } break; } case MotionEvent.ACTION_MOVE:{ int deltaX = x - mLastInterceptX; int deltaY = y - mLastInterceptY; if(Math.abs(deltaX)>Math.abs(deltaY)){ intercepted = true; }else { intercepted = false; } break; } case MotionEvent.ACTION_UP:{ intercepted = false; break; } default: break; } mLastX = x; mLastY = y; mLastInterceptX = x; mLastInterceptY = y; return intercepted; } @Override public boolean onTouchEvent(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()){ case ACTION_DOWN:{ if(!mScroller.isFinished()){ mScroller.abortAnimation(); } break; } case ACTION_MOVE:{ int deltaX = x - mLastX; int deltaY = y - mLastY; scrollBy(-deltaX,0); break; } case ACTION_UP:{ int scrollX = getScrollX(); mVelocityTracker.addMovement(event); 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 public void computeScroll() { if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } } private void smoothScrollBy(int dx,int dy){ mScroller.startScroll(getScrollX(),getScrollY(),dx,dy,700); invalidate(); } @Override protected void onDetachedFromWindow() { mVelocityTracker.recycle(); super.onDetachedFromWindow(); } }
onMeasure测量流程中的测量我们主要用的还是高度为match_parent,宽度为wrap_content的模式,这种模式下,依照我们的经验来说的话,容器的高度应该是是会填满整个屏幕的,直接设置高度为自身的高度,宽度就是每个子View的宽度乘以子View的数量。
onLayout中我们横向依次放置子View,每次放置完一个子View就将放置的起始点给叠加,达到横向ViewPager的效果。
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;//依次向右放置
}
}
我们可以在约束布局中测试这个布局实现的效果,我们在这个自定义ViewPager中放置三个宽度为500dp的TextView,并给其设置不同的背景色,需要注意的是这里给自定义ViewPager的横向约束只要给左边界一条就可以了:
再实际运行一下,就可以看到实际效果了:
自定义组合控件其实就是将多个组件结合起来形成一个整体的控件,首先要确定整体自定义控件的样式,在这里我们就写一个TitleBar,自带两个按钮可以设置点击事件:
下面是xml代码:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/layout_titlebar_rootlayout" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/iv_titlebar_left" android:layout_width="wrap_content" android:layout_height="fill_parent" android:layout_alignParentLeft="true" android:layout_centerInParent="true" android:paddingLeft="15dp" android:paddingRight="15dp" android:src="@drawable/baseline_agriculture_24"/> <TextView android:id="@+id/it_titlebar_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:ellipsize="end" android:maxEms="11" android:singleLine="true" android:textStyle="bold" /> <ImageView android:id="@+id/iv_titlebar_right" android:layout_width="wrap_content" android:layout_alignParentRight="true" android:layout_height="fill_parent" android:padding="15dp" android:gravity="center" android:src="@drawable/baseline_airport_shuttle_24"/> </RelativeLayout>
接着,我们需要将这个xml转化成View,主要是调用inflate方法:
LayoutInflater.from(context).inflate(R.layout.titlebar_layout,this,true);
与之前以往大多数不同的是,这里将这个xml创建出来之后直接加载进了这个类的根目录下,最后一个参数为true,这是最重要的一个部分,其他的代码部分就很简单了,大家可以自行理解,总的来说自定义组合控件本质上和其他自定义ViewGroup没有什么不同:
public class TitleBar extends RelativeLayout { private ImageView iv_titlebar_left; private ImageView iv_titlebar_right; private TextView tv_titlebar_title; private RelativeLayout layout_titlebar_rootlayout; private int mColor = Color.BLUE; private int mTextColor = Color.WHITE; private String mTitle = "Title"; public TitleBar(Context context) { super(context); initView(context); } public TitleBar(Context context, AttributeSet attrs) { super(context, attrs); TypedArray mTypedArray = context.obtainStyledAttributes(attrs,R.styleable.TitleBar); mColor = mTypedArray.getColor(R.styleable.TitleBar_title_bg,Color.BLUE); mTextColor = mTypedArray.getColor(R.styleable.TitleBar_title_text_color,Color.WHITE); mTitle = mTypedArray.getString(R.styleable.TitleBar_title_text); mTypedArray.recycle(); initView(context); } public TitleBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context); } public TitleBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public void initView(Context context){ LayoutInflater.from(context).inflate(R.layout.titlebar_layout,this,true); iv_titlebar_left = findViewById(R.id.iv_titlebar_left); iv_titlebar_right = findViewById(R.id.iv_titlebar_right); tv_titlebar_title = findViewById(R.id.it_titlebar_title); layout_titlebar_rootlayout = findViewById(R.id.layout_titlebar_rootlayout); //设置背景颜色 layout_titlebar_rootlayout.setBackgroundColor(mColor); //设置标题的字体颜色 tv_titlebar_title.setTextColor(mTextColor); setTitle(mTitle); } public void setTitle(String titleName){//设置标题内容 if(!TextUtils.isEmpty(titleName)){ tv_titlebar_title.setText(titleName); } } //设置左右图标的监听器 public void setLeftListener(OnClickListener onClickListener){ iv_titlebar_left.setOnClickListener(onClickListener); } public void setRightListener(OnClickListener onClickListener){ iv_titlebar_right.setOnClickListener(onClickListener); } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。