简介
什么是自定义view?我认为只要不是编译器直接提供可以使用的view,都可以认为是自定义view。自定义view主要分为两大类,第一类自定义view可以通过系统提供的各种view组合,样式变化实现的view。第二类是通过继承view或者ViewGroup类,通过ondraw方法绘制的view,onMeasure来定义视图的测量逻辑,onLayout来定义视图的布局逻辑,以及处理用户交互的方法。
入门-学会通过drawable和组合view实现
1、通过在drawable目录下创建一个xml文件
使用shape来实现各种矩形、圆角矩形、椭圆形、圆形、线条等。通过shape定义图形的边框、填充颜色、渐变、圆角半径等属性来创建各种视觉效果。这些shape可以用作背景、边框或者作为图形元素来装饰UI组件。
2、通过view的组合来实现想要的效果
1. layout_width:指定视图的宽度。
2. layout_height:指定视图的高度。
3. layout_margin:指定视图与其父布局或相邻视图之间的外边距。
4. layout_gravity:指定视图在其父布局中的对齐方式。
5. layout_weight:指定视图在线性布局中的权重,用于实现权重分配。
6. layout_alignParentTop、layout_alignParentBottom、layout_alignParentLeft、 layout_alignParentRight:用于相对布局,指定视图相对于父布局的对齐方式。
。。。
初级-通过ondraw绘制简单图形
1、了解canvas的一些常见操作
操作类型 | 相关API | 备注 |
---|---|---|
绘制颜色 | drawColor, drawRGB, drawARGB | 使用单一颜色填充整个画布 |
绘制基本形状 | drawPoint, drawPoints, drawLine, drawLines, drawRect, drawRoundRect, drawOval, drawCircle, drawArc | 依次为 点、线、矩形、圆角矩形、椭圆、圆、圆弧 |
绘制图片 | drawBitmap, drawPicture | 绘制位图和图片 |
绘制文本 | drawText, drawPosText, drawTextOnPath | 依次为 绘制文字、绘制文字时指定每一个文字位置、依据路径绘制文字 |
绘制路径 | drawPath | 绘制路径。绘制贝塞尔曲线时也须要用到该函数 |
顶点操作 | drawVertices, drawBitmapMesh | 通过对顶点操作能够使图像形变,drawVertices直接对画布作用、 drawBitmapMesh仅仅对绘制的Bitmap作用 |
画布剪裁 | clipPath, clipRect | 设置画布的显示区域 |
画布快照 | save, restore, saveLayerXxx, restoreToCount, getSaveCount | 依次为 保存当前状态、 回滚到上一次保存的状态、 保存图层状态、 回滚到指定状态、 获取保存次数 |
画布变换 | translate, scale, rotate, skew | 依次为 位移、缩放、 旋转、错切 |
Matrix(矩阵) | getMatrix, setMatrix, concat | 实际画布的位移。缩放等操作的都是图像矩阵Matrix,仅仅只是Matrix比較难以理解和使用。故封装了一些经常使用的方法。 |
2、了解自定义view的一些常见方法
class CustomView:View {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
return super.onTouchEvent(event)
}
}
这些构造方法允许你以不同的方式在代码中或者XML布局中创建自定义视图实例,并且提供了不同的参数组合来满足不同的需求。不写对应的构造方法,就不能使用这个方法去创建view。
1. constructor(context: Context?) : super(context):这个构造方法接受一个Context参数,用于在代码中动态创建视图实例。它调用了父类View的对应构造方法。
2. constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs):这个构造方法接受Context和AttributeSet参数,用于在XML布局文件中使用自定义视图时创建实例。它也调用了父类View的对应构造方法。
3. constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr):这个构造方法接受Context、AttributeSet和defStyleAttr参数,用于在XML布局文件中使用自定义视图时创建实例,并且指定了默认的样式。它同样调用了父类View的对应构造方法。
4. constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes):这个构造方法接受Context、AttributeSet、defStyleAttr和defStyleRes参数,用于在XML布局文件中使用自定义视图时创建实例,并且指定了默认的样式和主题。同样,它调用了父类View的对应构造方法。
下面的一些方法,是完成一个自定义view的核心方法。
1. onDraw(canvas: Canvas?):这个方法用于定义视图的绘制逻辑。你可以在这里使用Canvas对象来绘制你所需的图形、文本或者其他视觉元素。
2. onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int):这个方法用于定义视图的测量逻辑。在这里,你可以根据测量规格(MeasureSpec)来计算视图的宽度和高度,并通过setMeasuredDimension方法来设置测量结果。
在这个阶段,系统会通过调用View的measure()方法来测量View的大小。在测量过程中,View会确定自己的宽度和高度,并为其子View提供测量规格。 - View的测量规格通过MeasureSpec来表示,包括三种模式:EXACTLY、AT_MOST和UNSPECIFIED。 EXACTLY模式表示View的大小已经确定,如设置了具体的数值或match_parent属性。 AT_MOST模式表示View的大小不能超过某个边界,如设置了wrap_content属性。 UNSPECIFIED模式表示View的大小没有限制,如在ScrollView中的子View。 在measure()方法中,View会根据测量规格计算自己的测量宽度和高度,并通过setMeasuredDimension()方法设置测量结果。
3. onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int):这个方法用于定义视图的布局逻辑。在这里,你可以根据视图的尺寸和位置来安排视图的子视图的位置。
在这个阶段,系统会通过调用View的layout()方法来确定View在父容器中的位置。每个View都有自己的布局参数(LayoutParams),父容器会根据这些参数来摆放子View。 在layout()方法中,View会根据父容器传递的布局参数,计算自己的左上角坐标和右下角坐标,然后通过setFrame()方法设置自己的位置。
4. onTouchEvent(event: MotionEvent?):在这个方法中,你可以处理触摸事件,包括按下、移动、抬起等操作。根据需要,你可以返回true表示消费了该事件,或者返回false将事件传递给父视图或其他视图处理。
如果使用的是一个viewgroup,那么它还有一些额外的常用方法
class CustomViewGroup:ViewGroup {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
override fun onLayout(p0: Boolean, p1: Int, p2: Int, p3: Int, p4: Int) {
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
return super.onInterceptTouchEvent(ev)
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return super.dispatchTouchEvent(ev)
}
}
1. onLayout方法:这个方法用于定义子视图在ViewGroup中的布局位置。当ViewGroup需要摆放子视图时,系统会调用这个方法来指定子视图的位置。
2. onInterceptTouchEvent方法:这个方法用于拦截触摸事件。当自身需要拦截触摸事件时,可以重写这个方法来返回true,从而拦截事件的传递。
3. dispatchTouchEvent方法:这个方法用于分发触摸事件。当触摸事件到达ViewGroup时,系统会调用这个方法来分发事件给子视图或者自身进行处理。
通过重写这些方法,你可以实现自定义的布局逻辑、触摸事件处理逻辑,以及事件拦截逻辑,从而实现定制化的ViewGroup行为。
3、实践应用
绘制几个简单常用的图形
绘制扇形,可以通过代码看到,绘制起始位置是3点钟方向,而不是0点方向
class SectorView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
/**扇形的画笔*/
private var sectorPaint: Paint = Paint()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
initPaint()
drawSector(canvas)
}
/**
* 绘制扇形
* */
private fun drawSector(canvas: Canvas) {
val rect = RectF(
10f,
10f,
150f,
150f
)
canvas.drawArc(rect,0f,100f,true,sectorPaint)
}
/**
* 初始化画笔
* */
private fun initPaint() {
//当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式, 如圆形样Cap.ROUND,或方形样式Cap.SQUARE
sectorPaint.strokeCap = Paint.Cap.ROUND
//设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢。
sectorPaint.isAntiAlias = true
}
}
绘制饼状图就是绘制多个扇形,这里是写死的数据作为演示,需要动态修改扇形区域就要通过数据来计算每一个颜色区域的开始位置,所占的比例。
class SectorView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
/**扇形的画笔*/
private var sectorPaint: Paint = Paint()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
initPaint()
drawSector(canvas)
}
/**
* 绘制扇形
* */
private fun drawSector(canvas: Canvas) {
val rect = RectF(
10f,
10f,
150f,
150f
)
canvas.drawArc(rect,0f,100f,true,sectorPaint)
sectorPaint.color = Color.parseColor("#FFBB86FC")
canvas.drawArc(rect,100f,50f,true,sectorPaint)
sectorPaint.color = Color.parseColor("#FF6200EE")
canvas.drawArc(rect,150f,60f,true,sectorPaint)
sectorPaint.color = Color.parseColor("#FF03DAC5")
canvas.drawArc(rect,210f,150f,true,sectorPaint)
}
/**
* 初始化画笔
* */
private fun initPaint() {
//当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式, 如圆形样Cap.ROUND,或方形样式Cap.SQUARE
sectorPaint.strokeCap = Paint.Cap.ROUND
//设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢。
sectorPaint.isAntiAlias = true
sectorPaint.color = Color.parseColor("#FF000000")
}
}
仪表盘主要是先绘制一个圆,然后绘制刻度,刻度采用for循环,从第一个刻度开始,每次xy坐标按规律增加,特殊刻度就单独定制。这里的仪表盘,我们就通过系统提供的onMeasure方法获取到这个控件宽高,通过宽高来绘制仪表盘,就不会导致绘制的过大,超过显示范围,或者绘制的过小,也可以适配不同的屏幕分辨率。
class ClockView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private var mWidth = 0f
private var mHeight = 0f
override fun onDraw(canvas: Canvas) {
//画外圆
val paint = Paint()
paint.style = Paint.Style.STROKE
paint.strokeWidth = 2f
canvas.drawCircle(mWidth / 2, mHeight / 2, mWidth / 2, paint)
// canvas.drawLine(mWidth / 2, mHeight / 2, mWidth, mHeight, paint)
//画刻度线
val paint1 = Paint()
paint.strokeWidth = 3f
for (i in 0..23) {
if (i == 0 || i == 6 || i == 12 || i == 18) {
paint1.strokeWidth = 5f
paint1.textSize = 30f
canvas.drawLine(
mWidth / 2,
mHeight / 2 - mWidth / 2,
mWidth / 2,
mHeight / 2 - mWidth / 2 + 60,
paint1
)
val degree = i.toString()
canvas.drawText(
degree,
mWidth / 2 - paint1.measureText(degree) / 2,
mHeight / 2 - mWidth / 2 + 90,
paint1
)
} else {
paint1.strokeWidth = 3f
paint1.textSize = 15f
canvas.drawLine(
mWidth / 2,
mHeight / 2 - mWidth / 2,
mWidth / 2,
mHeight / 2 - mWidth / 2 + 30,
paint1
)
val degree = i.toString()
canvas.drawText(
degree,
mWidth / 2 - paint1.measureText(degree) / 2,
mHeight / 2 - mWidth / 2 + 60,
paint1
)
}
canvas.rotate(15f, mWidth / 2, mHeight / 2)
}
//画指针
val paintHour = Paint()
paintHour.strokeWidth = 20f
val paintMinute = Paint()
paintMinute.strokeWidth = 10f
canvas.save()
canvas.translate(mWidth / 2, mHeight / 2)
canvas.drawLine(0f, 0f, 100f, 100f, paintHour)
canvas.drawLine(0f, 0f, 100f, 200f, paintMinute)
canvas.restore()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
mWidth = measuredWidth.toFloat() - 20
mHeight = measuredHeight.toFloat() - 20
}
}
中级-掌握点击事件的传递分发
1、了解view的事件传递分发机制
初级的时候,我们只需要掌握绘制一些静态图形技巧,到了中级,我们就需要让这些view动起来,这个动起来效果,我们经常是设置不同的值,再通知view重新绘制,达到视觉上的一种动起来,实际上就是播放PPT。这里我们经常使用invalidate()与postInvalidate(),都用于刷新View,主要区别是invalidate()在主线程中调用,若在子线程中使用需要配合handler;而postInvalidate()可在子线程中直接调用。postInvalidate它是向主线程发送个Message,然后handleMessage时,调用了invalidate()函数。还需要掌握基本view的点击事件传递流程,会解决一些滑动冲突,判断滑动,点击事件的传递等。
通常情况下,我们写一个viewgroup不会去修改dispatchTouchEvent,因为我们如果直接在dispatchTouchEvent去把点击事件拦截了,其他人在你的viewgroup里面写的子view,就再也没办法获取到点击事件。所以我们一般都是在viewgroup的onInterceptTouchEvent去,viewgroup可以通过判断自己需要拦截的事件去处理并且拦截。同时子view可以通过调用requestDisallowInterceptTouchEvent(true)去阻止父布局拦截。
2、实践应用
竖直方向两个ScrollView,上下滑动时子ScrollView是无法响应竖直方向的滑动事件,我们需要子ScrollView可以上下滑动。
解决办法:1、重写子ScrollView的onInterceptTouchEvent方法,在这里添加parent.requestDisallowInterceptTouchEvent(true)方法,就可以让父控件不拦截事件。如果想要优化一下,让子ScrollView滑动到底部的时候父控件继续滑动,那就使用parent.requestDisallowInterceptTouchEvent(false),父控件会根据自身的判断来决定是否拦截。
如何区别不同的手势,点击,轻滑,长按,拖拽等。
解决办法:当然,系统提供的方法还是挺香的,Android sdk给我们提供了GestureDetector类
private class gesturelistener implements GestureDetector.OnGestureListener{
public boolean onDown(MotionEvent e) {
// TODO Auto-generated method stub
return false;
}
public void onShowPress(MotionEvent e) {
// TODO Auto-generated method stub
}
public boolean onSingleTapUp(MotionEvent e) {
// TODO Auto-generated method stub
return false;
}
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
// TODO Auto-generated method stub
return false;
}
public void onLongPress(MotionEvent e) {
// TODO Auto-generated method stub
}
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
float velocityY) {
// TODO Auto-generated method stub
return false;
}
}
这些函数的触发时机:
------GestureDetector.OnGestureListener事件执行顺序------
快速点击屏幕:onDown→onSingleTapUp
稍微慢速的点击屏幕:onDown→onShowPress→onSingleTapUp
长按屏幕:onDown→onShowPress→onLongPress
快速点击屏幕后滑动无惯性:onDown→onScroll→onScroll→onScroll...........
慢速点击屏幕后滑动无惯性:onDown→onShowPress→onScroll→onScroll→onScroll...........
快速点击屏幕后滑动有惯性:onDown→onScroll→onScroll→onScroll...........→onFling
慢速点击屏幕后滑动有惯性:onDown→onShowPress→onScroll→onScroll→onScroll...........→onFling
class MyDoubleTapListener implements GestureDetector.OnDoubleTapListener{
@Override
public boolean onSingleTapConfirmed(MotionEvent motionEvent) {
System.out.println("OnDoubleTapListener:" + "onSingleTapConfirmed");
return true;
}
@Override
public boolean onDoubleTap(MotionEvent motionEvent) {
System.out.println("OnDoubleTapListener:" + "onDoubleTap");
return true;
}
@Override
public boolean onDoubleTapEvent(MotionEvent motionEvent) {
System.out.println("OnDoubleTapListener:" + "onDoubleTapEvent");
return true;
}
}
onSingleTapConfirmed:单击事件,用来判定该次点击是单纯的SingleTap而不是DoubleTap。
onDoubleTap:双击事件
onDoubleTapEvent:双击间隔中发生的动作
这玩意的用法也很简单,在ontouch方法里面,把event传进去就行了mGestureDetector.onTouchEvent(event);或者需要缩放判断的时候采用ScaleGestureDetector。
系统方法虽然用起来简单,但是总会出现不满足业务需求的时候,那就只有靠自己手撸了。
我们可以在down的时候记录按下的时间,这个时候就可以在up 的时候通过时间去判断是长按还是短按,或者自己设置不同的点击时长去做不同的处理。在按下到抬起这个时间段,我们可以在move的监听里面去做一些滑动的判断,比如滑动距离很短并且时间也短的时候就抬起了,认为是轻扫,滑动距离长,时间长,认为是滑动拖拽等。主要是通过滑动时间 + 滑动距离去做自己的手势判断。
高级-融合贯通
何谓高级?高级其实就是更懂初级中级的一些操作,更能深入理解里面的逻辑和实现,能够自己通过不同的方式方法去灵活的实现自己的业务需求。列出一些常见的问题或者解决方式,学会自己去构造属于自己的自定义view的世界。
1、自定义view的时候,适配问题?
自己写一个控件的时候,宽高不要写死,从onMeasure获取到这个控件的宽高,有些控件需要计算某个刻度或者线条的长度啥的,尽量按照控件宽高的比例去获取,不要写死(采用不同分辨率获取不同的数值的适配方案还是可以写死的)。
2、点击事件的处理
自己不需要处理的事件就不要去拦截,只拦截自己需要处理的时候,避免其他人使用你的控件的时候,出现了滑动冲突,他不能修改自己的控件逻辑去解决,必须要改你的控件。
3、考虑边界问题
有些时候做一些图表的时候,有些点的位置很高或者很低的时候,再去根据这个点去绘制其它文字,就可能造成文字显示不全等问题。
4、数据来源
可以通过xml里面设置自定义属性的声明文件,自定义view的时候通过obtainStyledAttributes方法获取自定义属性的值,并在View的初始化中使用这些值。
通过自定义view暴露的方法设置数据,通常建议在调用前组装好数据,直接传递给view使用,而不是在view里面去组装数据。
。。。。