在Android开发中,自定义控件是突破原生控件局限、实现个性化UI与交互的核心技能,也是从中级开发者进阶到高级开发者的必备能力,无论是产品要求的独特视觉风格(如自定义图表、不规则按钮),还是复杂的交互逻辑(如滑动删除、下拉刷新),亦或是性能敏感场景的布局优化,都离不开自定义控件的灵活运用,然而,自定义控件涉及View生命周期、绘制流程、事件分发、属性配置等多个核心知识点,学习路径零散且门槛较高,容易让开发者陷入“知其然不知其所以然”的困境,本文围绕Android自定义控件的9大核心模块,从基础认知到实战实操,从进阶拓展到问题排查,以体系化的问答形式拆解全知识点,既覆盖“是什么、为什么”的理论逻辑,也明确“怎么做、如何优化”的实操步骤,帮助开发者从0到1构建完整的自定义控件知识体系,轻松应对各类业务场景的开发需求。
知识点汇总:

一、基础认知类(了解自定义控件核心概念)
1.1、什么是Android自定义控件?它与系统原生控件的区别是什么?
Android自定义控件是基于系统View/ViewGroup扩展,按需定制UI样式、交互逻辑的控件,与原生控件的核心区别在于“灵活性”和“针对性”。
定义:自定义控件是通过重写View/ViewGroup的关键方法(如测量、绘制、事件处理)或组合现有控件,实现系统原生控件不具备的UI样式、交互逻辑或功能的控件。
核心区别:
功能覆盖:原生控件(如TextView、Button)满足通用场景,功能固定,自定义控件针对特定业务场景,功能可灵活定制。
灵活性:原生控件样式、交互受系统限制,难以修改,自定义控件可完全掌控UI绘制、触摸反馈等细节。
复杂度:原生控件开箱即用,无需额外开发,自定义控件需手动处理测量、布局、事件等,开发成本更高。
复用性:原生控件是通用复用,自定义控件是业务场景化复用(可封装成组件供项目内多次使用)。
1.2、为什么需要使用自定义控件?哪些场景下推荐使用自定义控件?
使用自定义控件是为了弥补原生控件的功能/样式局限性,推荐在“原生控件无法满足需求”的场景下使用。
核心原因:
原生控件功能不足:如需要带进度的圆形头像、横向滚动的标签栏等,原生控件无法直接实现。
UI样式个性化:产品设计要求独特风格(如不规则按钮、渐变背景、自定义图表),原生控件难以匹配。
交互逻辑特殊:如自定义下拉刷新、滑动删除、手势缩放等,原生控件的交互方式无法满足。
统一UI风格与复用:项目中多个页面需要相同样式/交互的控件(如自定义标题栏),封装成自定义控件可减少重复代码。
性能优化:原生控件组合可能导致布局层级过深,自定义控件可简化层级、提升绘制效率。
推荐场景:
个性化UI展示:如自定义图表(折线图、饼图)、特殊形状控件(圆形、圆角矩形、不规则图形)。
特殊交互需求:如滑动选择器、拖拽排序、手势解锁、自定义下拉刷新/上拉加载。
组合式复用控件:如包含“返回按钮+标题+右侧菜单”的通用标题栏、包含“输入框+清除按钮+图标”的自定义EditText。
性能敏感场景:如RecyclerView的Item布局复杂,通过自定义ViewGroup简化层级,减少过度绘制。
1.3、Android自定义控件主要分为哪几类?各类的适用场景是什么?
Android自定义控件主要分为三类,自定义View、自定义ViewGroup、组合控件,适用场景分别对应“单一视图”“容器布局”“原生控件组合复用”,分类及适用场景如下。
一、自定义View(继承View/TextView等单一View)
核心特点:无子View,专注于自身的绘制和单一交互。
适用场景:实现单一形态的个性化UI,如圆形头像、进度条、自定义文本、简单图形(三角形、五角星)。
二、自定义ViewGroup(继承ViewGroup/LinearLayout等容器)
核心特点:包含子View,需管理子View的测量、布局、事件分发。
适用场景:实现自定义布局排版,如流式布局(标签自动换行)、网格布局(自定义行列间距)、滑动容器(横向滚动的子View容器)。
三、组合控件(组合多个原生控件)
核心特点:不直接重写绘制/测量逻辑,而是通过XML布局或代码组合现有原生控件,封装成新控件。
适用场景:复用组合式UI,如通用标题栏(TextView+ImageView+Button)、自定义搜索框(EditText+ImageView)、带图标的按钮(ImageView+TextView)。
1.4、自定义View和自定义ViewGroup的核心区别是什么?分别用于解决什么问题?
核心区别在于“是否管理子View”,自定义View解决“单一UI/交互”问题,自定义ViewGroup解决“多子View排版与管理”问题。
核心区别:
职责不同:自定义View仅负责自身的测量(Measure)、绘制(Draw)和触摸事件(TouchEvent),自定义ViewGroup除了自身的测量/绘制,还需负责子View的测量、布局(Layout)和事件分发(拦截/传递)。
核心方法不同:自定义View重点重写onMeasure()(测量自身宽高)、onDraw()(绘制自身UI),自定义ViewGroup重点重写onMeasure()(测量子View+自身宽高)、onLayout()(摆放子View位置),可选重写onInterceptTouchEvent()(拦截子View事件)。
适用对象不同:自定义View无子View,是“单一视图”,自定义ViewGroup包含子View,是“容器视图”。
解决的问题:
自定义View:解决“原生单一控件无法满足的UI样式或交互”问题,如自定义进度条(单一视图+进度绘制)、手势控制的圆形开关(单一视图+触摸交互)。
自定义ViewGroup:解决“原生布局管理器无法满足的子View排版”问题,如流式布局(子View自动换行)、垂直滚动的多子View容器(自定义子View布局顺序)、支持子View拖拽排序的容器(管理子View位置更新)。
1.5、自定义控件的核心设计思路是什么?从0设计自定义控件的基本流程有哪些?
核心设计思路是“按需定制、高复用、优性能”,基本流程围绕“需求分析→技术选型→实现→优化→测试”展开。
核心设计思路:
按需定制:只实现必要的功能,不冗余(如仅需自定义样式则不重写事件逻辑)。
高复用性:提取通用属性(如颜色、尺寸、文本)通过XML配置,暴露对外API和回调接口。
性能优先:避免过度绘制(如减少onDraw()中不必要的绘制)、优化测量布局(如避免重复测量子View)。
兼容性:考虑不同Android版本、屏幕尺寸的适配(如处理API版本差异、支持dp单位)。
从0设计的基本流程:
需求分析:明确控件的UI样式(如形状、颜色、文本)、交互逻辑(如点击、滑动、手势)、复用场景(是否支持XML配置、属性定制)。
技术选型:根据需求选择自定义控件类型(自定义View/ViewGroup/组合控件),确定核心重写方法。
定义自定义属性:在attr.xml中声明控件的可配置属性(如app:radius(圆角半径)、app:progressColor(进度颜色))。
核心方法实现:
构造方法:初始化属性(通过TypedArray获取XML配置的属性值)、初始化画笔(Paint)、动画等。
测量逻辑:重写onMeasure(),计算控件自身宽高(自定义View)或子View+自身宽高(自定义ViewGroup)。
布局/绘制逻辑:自定义View重写onDraw()绘制UI,自定义ViewGroup重写onLayout()摆放子View。
事件处理:重写onTouchEvent()实现触摸交互,自定义ViewGroup可重写onInterceptTouchEvent()处理事件拦截。
暴露API与回调:提供对外方法(如setProgress(int progress)设置进度)、定义回调接口(如OnProgressChangeListener进度变化监听)。
性能优化:减少onDraw()中对象创建、避免过度绘制、优化测量布局逻辑。
测试与适配:在不同设备/系统版本测试,验证UI显示、交互逻辑、兼容性是否正常。
二、前置知识准备类(掌握自定义控件基础依赖)
2.1、View的生命周期包含哪些关键方法?各方法的调用时机是什么?
View的生命周期围绕“依附窗口→布局→绘制→销毁”展开,关键方法按执行顺序为:onAttachedToWindow()→onMeasure()→onLayout()→onDraw()→onDetachedFromWindow(),另有状态变化相关辅助方法。
关键方法及调用时机:
onAttachedToWindow():View被添加到Window时调用(首次依附窗口),可在此初始化资源(如画笔、动画)。
onMeasure(int widthMeasureSpec, int heightMeasureSpec):测量View宽高,每次布局变化(如屏幕旋转、父View重绘)时都会调用。
onLayout(boolean changed, int left, int top, int right, int bottom):确定View在父容器中的位置,测量完成后调用,位置变化时会重复执行。
onDraw(Canvas canvas):绘制View的UI内容(图形、文字、图片),测量和布局完成后触发,invalidate()/postInvalidate()调用后也会执行。
onVisibilityChanged(View changedView, int visibility):View可见性改变时调用(如VISIBLE/INVISIBLE/GONE切换)。
onDetachedFromWindow():View从Window中移除时调用(如Activity销毁、View被移除),可在此释放资源(如回收画笔、停止动画)。
onSizeChanged(int w, int h, int oldw, int oldh):View宽高发生变化时调用(首次测量后或后续宽高修改),可在此处理尺寸相关逻辑。
自定义View绘制流程函数调用链图解(简化版):

2.2、Android中View的绘制流程(Measure、Layout、Draw)具体是怎样的?
View绘制流程是“自上而下”的递归过程,核心分三步:Measure(测量宽高)→Layout(确定位置)→Draw(绘制内容),父View先完成自身逻辑再处理子View。
整体流程:
触发时机:首次加载布局、View可见性变化、调用requestLayout()/invalidate()、屏幕旋转等。
执行顺序:从根View(DecorView)开始,递归遍历所有子View,依次执行Measure→Layout→Draw,子View的绘制依赖父View的测量和布局结果。
各阶段详细说明:
Measure阶段:
作用:计算View的宽高(mMeasuredWidth/mMeasuredHeight)。
流程:父View通过measureChild()传递测量规格(MeasureSpec),子View重写onMeasure()计算自身宽高,最终调用setMeasuredDimension()保存测量结果。
特点:ViewGroup会先测量自身,再遍历子View执行测量,确保子View宽高适配父容器。
Layout阶段:
作用:确定View在父容器中的具体位置(left、top、right、bottom坐标)。
流程:父View重写onLayout(),通过child.layout()为每个子View分配位置,子View接收坐标后确定自身在父容器中的布局区域。
特点:仅ViewGroup需重写onLayout()(管理子View位置),单一View无需重写(默认使用父View分配的位置)。
Draw阶段:
作用:根据测量和布局结果,在Canvas上绘制UI内容。
流程:
1、绘制背景(drawBackground())。
2、绘制自身内容(onDraw(),自定义View核心绘制方法)。
3、绘制子View(dispatchDraw(),ViewGroup专属,递归绘制子View)。
4、绘制装饰(如滚动条、前景色,onDrawForeground())。
特点:单一View无dispatchDraw(),ViewGroup通过该方法触发子View绘制。
2.3、View的测量规格(MeasureSpec)是什么?EXACTLY、AT_MOST、UNSPECIFIED分别代表什么含义?
MeasureSpec是View测量的“宽高规格”,由32位int值组成(高2位为模式,低30位为尺寸),三种模式对应不同的宽高约束逻辑。
核心定义:
MeasureSpec由父View的测量规格和子View的LayoutParams(如match_parent/wrap_content)共同决定,用于约束子View的宽高范围。
工具类:View.MeasureSpec提供makeMeasureSpec()(创建规格)、getMode()(获取模式)、getSize()(获取尺寸)方法。
三种模式含义及适用场景:
EXACTLY(精确模式,对应MeasureSpec.EXACTLY):
含义:View宽高为确定值,由MeasureSpec的低30位尺寸直接决定。
适用场景:子View的LayoutParams为match_parent(占满父View剩余空间)或具体数值(如100dp),父View会传递精确尺寸。
AT_MOST(最大模式,对应MeasureSpec.AT_MOST):
含义:View宽高不能超过MeasureSpec的低30位尺寸,需根据自身内容计算实际宽高(不能超出最大值)。
适用场景:子View的LayoutParams为wrap_content,父View传递最大可用尺寸,子View需自行约束宽高不超限。
UNSPECIFIED(无限制模式,对应MeasureSpec.UNSPECIFIED):
含义:View宽高无约束,可根据自身内容自由计算(父View不限制最大尺寸)。
适用场景:多出现于ScrollView、ListView等可滚动容器,子View需完全展示自身内容(如ScrollView的子View高度可无限延伸)。
2.4、什么是View的事件分发机制?dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent的关系是什么?
View事件分发是“触摸事件(MotionEvent)从父View传递到子View,再由子View决定是否消费”的过程,三个方法构成“分发→拦截→响应”的核心逻辑。
核心定义:
触摸事件:用户触摸屏幕产生的事件(如ACTION_DOWN(按下)、ACTION_MOVE(滑动)、ACTION_UP(抬起)),以事件序列形式传递(从DOWN到UP为一个完整序列)。
分发目标:事件最终会传递到“消费该事件”的View(onTouchEvent()返回true),若无人消费则最终由Activity处理。
三个方法的作用及关系:
dispatchTouchEvent(MotionEvent ev):
作用:事件分发的入口方法,决定事件是否继续传递、是否拦截、是否自身处理。
返回值:true表示事件已消费(终止传递),false表示事件未消费(回传给父View)。
onInterceptTouchEvent(MotionEvent ev):
作用:仅ViewGroup拥有,决定是否拦截事件(阻止事件传递给子View)。
返回值:true表示拦截(事件交给自身onTouchEvent()处理),false表示不拦截(事件继续传递给子View)。
onTouchEvent(MotionEvent ev):
作用:处理触摸事件(如点击、滑动),决定是否消费事件。
返回值:true表示消费事件(事件序列终止),false表示不消费(事件回传给父View的onTouchEvent())。
核心传递逻辑:
自上而下分发:事件从Activity→根View→父View→子View,依次调用dispatchTouchEvent()。
拦截逻辑:ViewGroup在dispatchTouchEvent()中调用onInterceptTouchEvent(),若返回true则中断传递,自身处理。
自下而上响应:子ViewonTouchEvent()返回false时,事件回传给父View的onTouchEvent(),直至有人消费或返回Activity。
事件分发机制图解:

2.5、AttributeSet和TypedArray的作用是什么?如何通过它们获取XML中的属性值?
AttributeSet是XML布局中View属性的“原始集合”,TypedArray是“属性解析工具”,通过两者配合可获取自定义控件的XML配置属性,核心是“声明属性→解析属性→回收资源”,两者核心作用如下。
AttributeSet:
作用:存储XML布局中View的所有属性(包括系统属性和自定义属性),如android:layout_width、app:customColor。
特点:仅提供原始属性值(字符串形式),需手动转换类型(如字符串转int),直接使用不便。
TypedArray:
作用:封装了AttributeSet的属性解析逻辑,可快速获取指定类型的属性值(如int、float、Color),自动处理属性默认值。
特点:使用后必须回收(避免内存泄漏),是自定义控件获取XML属性的标准方式。
获取XML属性值的步骤:
声明自定义属性:在res/values/attr.xml中定义declare-styleable,声明属性名称和格式(format)。
<declare-styleable name="CustomView"><attr name="customColor" format="color" /><attr name="customSize" format="dimension" /></declare-styleable>
在自定义View构造方法中获取AttributeSet:
public CustomView(Context context, AttributeSet attrs) {super(context, attrs);// 解析属性TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CustomView);// 获取属性值(第二个参数为默认值)int customColor = ta.getColor(R.styleable.CustomView_customColor, Color.BLACK);float customSize = ta.getDimension(R.styleable.CustomView_customSize, 16f);// 回收TypedArray(必须调用)ta.recycle();}
XML布局中引用属性:需声明自定义命名空间(如xmlns:app="http://schemas.android.com/apk/res-auto"),再使用app:customColor配置。
TypedArray回收原因:TypedArray内部持有系统资源,不回收会导致资源泄漏,recycle()方法会释放内部资源,避免内存占用。
2.6、Android中的坐标系(屏幕坐标系、View坐标系)有什么区别?如何获取View的坐标?
两种坐标系的核心区别是“原点位置”,屏幕坐标系原点在屏幕左上角,View坐标系原点在父View左上角,获取坐标的方法需匹配对应坐标系。
两种坐标系详细区别:

常用坐标获取方法(按坐标系分类):
一、屏幕坐标系相关方法
getLocationOnScreen(int[] location):获取View左上角在屏幕坐标系的坐标,location[0]为x值,location[1]为y值。
getRawX()/getRawY():在onTouchEvent()中使用,获取触摸点在屏幕坐标系的坐标。
二、View坐标系相关方法
getLeft()/getTop():View左上角相对于父View的x/y坐标。
getRight()/getBottom():View右下角相对于父View的x/y坐标(right=left+width,bottom=top+height)。
getX()/getY():在onTouchEvent()中使用,获取触摸点相对于当前View左上角的坐标。
getTranslationX()/getTranslationY():View的平移距离(相对于原始位置),最终坐标=原始坐标+平移距离。
补充说明:View的width=getRight()-getLeft(),height=getBottom()-getTop(),与坐标系无关,仅由测量结果决定。
图解:

三、入门实现类(自定义控件基础实操)
3.1、自定义View的最简化实现需要重写哪些方法?如何实现一个简单的静态自定义View(如圆形、矩形)?
最简化实现需重写构造方法(初始化资源)和onDraw()(绘制UI),静态形状的核心是通过Canvas绘制图形,配合Paint设置样式,最简化需重写的方法如下。
构造方法:至少重写CustomView(Context context, AttributeSet attrs),用于初始化画笔(Paint)、属性等资源(从XML加载时会调用此构造)。
onDraw(Canvas canvas):核心绘制方法,在此通过Canvas的API(如drawCircle、drawRect)绘制UI内容。
实现简单静态自定义View(以圆形为例):
public class CircleView extends View {private Paint mPaint; // 画笔// 构造方法:从XML加载时调用public CircleView(Context context, AttributeSet attrs) {super(context, attrs);initPaint(); // 初始化画笔}// 初始化画笔:设置颜色、抗锯齿等private void initPaint() {mPaint = new Paint();mPaint.setColor(Color.RED); // 圆形颜色mPaint.setAntiAlias(true); // 抗锯齿(边缘平滑)mPaint.setStyle(Paint.Style.FILL); // 填充模式}protected void onDraw(Canvas canvas) {super.onDraw(canvas);// 获取View的宽高(测量后的值)int width = getWidth();int height = getHeight();// 计算圆心(View中心)和半径(取宽高最小值的一半)int centerX = width / 2;int centerY = height / 2;int radius = Math.min(width, height) / 2;// 绘制圆形canvas.drawCircle(centerX, centerY, radius, mPaint);}}
3.2、如何在XML布局中引用自定义控件?需要注意哪些命名空间相关的问题?
XML中通过全类名引用自定义控件,需声明自定义命名空间(如app:),避免硬编码包名,引用步骤如下。
在布局文件中声明自定义命名空间(通常用app作为前缀):
<!-- 根布局中添加命名空间:res-auto自动关联当前应用包名 -->xmlns:app="http://schemas.android.com/apk/res-auto"
通过全类名(包名+类名)引用自定义控件:
<com.example.customview.CircleViewandroid:layout_width="100dp"android:layout_height="100dp"android:layout_centerInParent="true"/>
命名空间注意事项:
1、命名空间前缀(如app)可自定义(如custom),但需保持一致。
2、必须使用res-auto而非硬编码包名(如http://schemas.android.com/apk/res/com.example.customview),否则包名修改后会失效。
3、系统属性用android:前缀(如android:layout_width),自定义属性用声明的前缀(如app:customColor)。
3.3、如何自定义控件的属性(attr.xml文件的编写)?属性的格式(format)有哪些类型?
通过res/values/attr.xml的declare-styleable声明属性,format指定属性类型,支持多种基础类型和复合类型,attr.xml文件编写步骤如下。
1、在res/values目录下创建attr.xml。
2、用declare-styleable标签包裹属性,name通常与自定义View类名一致(便于关联)。
3、每个attr标签定义属性名(name)和类型(format)。
示例(为CircleView添加颜色和半径属性):
<resources><!-- 关联CircleView的属性集合 --><declare-styleable name="CircleView"><!-- 圆形颜色:format为color --><attr name="circleColor" format="color" /><!-- 圆半径:format为dimension(支持dp/sp) --><attr name="circleRadius" format="dimension" /></declare-styleable></resources>
常用format类型:

3.4、自定义View中如何获取自定义属性的值?TypedArray使用后为什么需要回收?
通过TypedArray从AttributeSet中解析属性值,TypedArray需回收是因为其内部持有系统资源,不释放会导致内存泄漏。
获取自定义属性的步骤:
1、在自定义View的构造方法中,通过context.obtainStyledAttributes()获取TypedArray(关联declare-styleable)。
2、调用TypedArray的getXxx()方法(如getColor()、getDimension())获取属性值(需传入默认值,避免属性未配置时出错)。
3、调用typedArray.recycle()回收资源。
代码示例(为CircleView获取circleColor和circleRadius):
public class CircleView extends View {private int mCircleColor; // 圆形颜色private float mCircleRadius; // 圆半径private Paint mPaint;public CircleView(Context context, AttributeSet attrs) {super(context, attrs);// 1. 获取TypedArray(关联R.styleable.CircleView)TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);// 2. 获取属性值:第二个参数为默认值mCircleColor = ta.getColor(R.styleable.CircleView_circleColor, Color.RED);mCircleRadius = ta.getDimension(R.styleable.CircleView_circleRadius, 50dp); // 需转px,实际用getResources().getDimension()// 3. 回收TypedArray(必须调用)ta.recycle();initPaint();}private void initPaint() {mPaint = new Paint();mPaint.setColor(mCircleColor); // 使用获取的颜色mPaint.setAntiAlias(true);}protected void onDraw(Canvas canvas) {super.onDraw(canvas);// 用获取的半径绘制canvas.drawCircle(getWidth()/2, getHeight()/2, mCircleRadius, mPaint);}}
TypedArray回收的原因:TypedArray是系统资源池中的共享资源,内部持有Parcel和AssetManager等对象,若不调用recycle(),这些资源不会被释放,会导致资源泄漏(尤其是频繁创建View时),recycle()方法会将其归还给系统资源池,供其他地方复用,避免内存浪费。
3.5、自定义ViewGroup的最简化实现需要重写哪些方法?如何实现一个包含多个子View的简单容器?
自定义ViewGroup需重写构造方法、onMeasure()(测量子View和自身宽高)、onLayout()(摆放子View位置),简单容器的核心是遍历子View执行测量和布局,最简化需重写的方法如下。
构造方法:至少重写CustomViewGroup(Context context, AttributeSet attrs),用于初始化。
onMeasure(int widthMeasureSpec, int heightMeasureSpec):测量所有子View的宽高(调用child.measure()),根据子View的测量结果,计算自身的宽高(调用setMeasuredDimension()保存)。
onLayout(boolean changed, int left, int top, int right, int bottom):遍历子View,调用child.layout()为每个子View分配位置(指定左上角和右下角坐标)。
实现简单容器(垂直排列的容器):
public class VerticalContainer extends ViewGroup {public VerticalContainer(Context context, AttributeSet attrs) {super(context, attrs);}protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {// 1. 测量所有子Viewint childCount = getChildCount();for (int i = 0; i < childCount; i++) {View child = getChildAt(i);// 测量子View:传入父View的测量规格(子View需遵守父View的约束)measureChild(child, widthMeasureSpec, heightMeasureSpec);}// 2. 计算自身宽高(垂直排列:宽=父View给的宽,高=所有子View高度之和)int width = MeasureSpec.getSize(widthMeasureSpec); // 宽=父View指定的宽int height = 0;for (int i = 0; i < childCount; i++) {height += getChildAt(i).getMeasuredHeight(); // 累加子View高度}// 保存自身测量结果setMeasuredDimension(width, height);}protected void onLayout(boolean changed, int left, int top, int right, int bottom) {int childCount = getChildCount();int currentTop = top; // 当前子View的顶部坐标(从父View的top开始)for (int i = 0; i < childCount; i++) {View child = getChildAt(i);// 子View的宽高(测量后的值)int childWidth = child.getMeasuredWidth();int childHeight = child.getMeasuredHeight();// 摆放子View:left=父View的left,top=currentTop,right=left+childWidth,bottom=currentTop+childHeightchild.layout(left, currentTop, left + childWidth, currentTop + childHeight);// 更新currentTop,为下一个子View预留位置currentTop += childHeight;}}}
3.6、如何为自定义控件设置默认样式(style)和主题(theme)相关的属性?
通过declare-styleable定义属性默认值或在构造方法中关联主题属性,实现默认样式,主题相关属性需在attrs.xml中声明为reference类型,并在主题中定义。
设置默认样式(style):
在attr.xml中为属性指定defaultValue(静态默认值):
<declare-styleable name="CircleView"><attr name="circleColor" format="color" defaultValue="#FF0000" /> <!-- 默认红色 --></declare-styleable>
定义独立样式(res/values/styles.xml),并在XML中引用:
<!-- 定义样式 --><style name="DefaultCircle"><item name="circleColor">#00FF00</item><item name="circleRadius">60dp</item></style><!-- 引用样式 --><com.example.customview.CircleViewstyle="@style/DefaultCircle"android:layout_width="100dp"/>
关联主题(theme)属性:
在attr.xml中声明引用主题属性的自定义属性(format="reference"):
<!-- 声明主题属性(可在theme中定义) --><attr name="circleThemeColor" format="color" /><!-- 自定义View属性引用主题属性 --><declare-styleable name="CircleView"><attr name="circleColor" format="color" /></declare-styleable>
在主题(res/values/themes.xml)中定义属性值:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight"><item name="circleThemeColor">#0000FF</item> <!-- 主题中定义默认颜色 --></style>
在自定义View构造方法中,通过defStyleAttr关联主题属性(若未在XML中配置circleColor,则使用主题中的circleThemeColor):
public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);// 从主题中获取默认值:第三个参数defStyleAttr指定主题属性TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView,R.attr.circleThemeColor, 0); // 关联circleThemeColormCircleColor = ta.getColor(R.styleable.CircleView_circleColor, Color.RED);ta.recycle();}
通过以上方式,自定义控件可实现“XML配置优先→样式次之→主题默认→代码默认”的属性优先级逻辑,灵活适配不同场景。
四、进阶绘制类(掌握View的核心绘制逻辑)
4.1、onMeasure方法的作用是什么?如何正确重写onMeasure实现自定义View的宽高测量?
onMeasure的核心作用是测量View的宽高,通过解析父View传递的MeasureSpec,结合自身需求计算出mMeasuredWidth和mMeasuredHeight,并调用setMeasuredDimension()保存结果,正确重写需覆盖三种MeasureSpec模式,确保宽高计算符合预期。
onMeasure的核心作用:
1、接收父View传递的宽高测量规格(MeasureSpec),包含模式和约束尺寸。
2、根据测量规格和自身业务逻辑(如内容大小、自定义属性),计算View的实际宽高。
3、通过setMeasuredDimension(int measuredWidth, int measuredHeight)保存测量结果,为后续Layout阶段提供位置计算依据。
4、若为ViewGroup,还需触发所有子View的测量(measureChild()/measureChildren())。
正确重写onMeasure的步骤:
1、解析宽高的MeasureSpec,获取模式(getMode())和约束尺寸(getSize())。
2、针对不同模式计算实际宽高:
EXACTLY:直接使用约束尺寸(如match_parent或具体数值)。
AT_MOST:计算自身内容所需尺寸,且不超过约束尺寸(如wrap_content)。
UNSPECIFIED:直接使用自身内容所需尺寸(无约束)。
3、调用setMeasuredDimension()保存最终宽高,不可遗漏(否则会抛出异常)。
代码示例:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);// 解析宽的测量规格int widthMode = MeasureSpec.getMode(widthMeasureSpec);int widthSize = MeasureSpec.getSize(widthMeasureSpec);// 解析高的测量规格int heightMode = MeasureSpec.getMode(heightMeasureSpec);int heightSize = MeasureSpec.getSize(heightMeasureSpec);// 计算实际宽高(以“内容宽高为100dp”为例)int defaultSize = dp2px(100); // 自定义默认尺寸(wrap_content时使用)int measuredWidth = calculateSize(widthMode, widthSize, defaultSize);int measuredHeight = calculateSize(heightMode, heightSize, defaultSize);// 保存测量结果setMeasuredDimension(measuredWidth, measuredHeight);}// 辅助方法:根据模式计算尺寸private int calculateSize(int mode, int size, int defaultSize) {switch (mode) {case MeasureSpec.EXACTLY:return size; // 精确模式:使用父View约束尺寸case MeasureSpec.AT_MOST:return Math.min(defaultSize, size); // 最大模式:取内容尺寸和约束尺寸的最小值case MeasureSpec.UNSPECIFIED:return defaultSize; // 无约束模式:使用内容尺寸default:return defaultSize;}}// dp转px工具方法private int dp2px(float dp) {return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,getResources().getDisplayMetrics());}
4.2、自定义View中wrap_content属性默认不生效的原因是什么?如何解决?
wrap_content默认不生效,是因为View的默认onMeasure逻辑中,wrap_content(对应AT_MOST模式)和match_parent(对应EXACTLY模式)共用了同一套处理逻辑(直接使用父View约束尺寸),解决核心是在onMeasure中单独处理AT_MOST模式,计算自身内容尺寸。
默认不生效的原因:
1、View的父类(android.view.View)默认onMeasure实现中,未区分wrap_content和match_parent的逻辑。
2、当布局参数为wrap_content时,父View会传递AT_MOST模式的MeasureSpec,但默认逻辑直接将约束尺寸(父View可用空间)作为自身宽高,导致wrap_content效果等同于match_parent。
解决方法(核心步骤):
1、在重写的onMeasure中,识别宽高的MeasureSpec模式为AT_MOST(即wrap_content)。
2、计算自定义View的“内容所需尺寸”(如绘制的图形大小、文字宽度等)。
3、将“内容尺寸”与父View的约束尺寸对比,取最小值作为最终宽高(避免超出父View范围)。
代码示例(延续上面的自定义View):
// 假设自定义View是绘制一个圆形,内容尺寸为“半径*2”private float mRadius = dp2px(50); // 圆形半径(内容核心尺寸)protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {int widthMode = MeasureSpec.getMode(widthMeasureSpec);int widthSize = MeasureSpec.getSize(widthMeasureSpec);int heightMode = MeasureSpec.getMode(heightMeasureSpec);int heightSize = MeasureSpec.getSize(heightMeasureSpec);// 计算内容所需尺寸(圆形直径=半径*2)int contentWidth = (int) (mRadius * 2);int contentHeight = (int) (mRadius * 2);// 处理wrap_content(AT_MOST模式)int measuredWidth = contentWidth;if (widthMode == MeasureSpec.EXACTLY) {measuredWidth = widthSize;} else if (widthMode == MeasureSpec.AT_MOST) {measuredWidth = Math.min(contentWidth, widthSize); // 不超过父View约束}int measuredHeight = contentHeight;if (heightMode == MeasureSpec.EXACTLY) {measuredHeight = heightSize;} else if (heightMode == MeasureSpec.AT_MOST) {measuredHeight = Math.min(contentHeight, heightSize);}setMeasuredDimension(measuredWidth, measuredHeight);}
4.3、onLayout方法的核心作用是什么?自定义ViewGroup中如何通过onLayout摆放子View的位置?
onLayout的核心作用是确定View(或子View)在父容器中的具体位置,通过设置left、top、right、bottom四个坐标实现布局,自定义ViewGroup需遍历所有子View,计算每个子View的坐标并调用child.layout()完成摆放。
onLayout的核心作用:
1、接收父容器传递的自身布局区域(left/top/right/bottom),确定自身在父容器中的位置。
2、对于ViewGroup,需根据布局规则(如线性排列、网格排列),为每个子View分配独立的布局区域。
3、触发子View的onLayout()方法,完成子View的最终位置确定(递归流程)。
4、仅当View的布局区域发生变化时(如changed参数为true),需重新计算位置。
自定义ViewGroup中摆放子View的步骤:
1、重写onLayout(boolean changed, int left, int top, int right, int bottom),changed为true时才重新布局(优化性能)。
2、遍历所有子View(getChildCount()/getChildAt(i)),跳过不可见(GONE)的子View。
3、获取每个子View的测量宽高(child.getMeasuredWidth()/child.getMeasuredHeight())。
4、根据布局规则(如垂直排列、水平排列),计算子View的left/top/right/bottom坐标。
5、调用child.layout(left, top, right, bottom),将子View摆放到指定位置。
代码示例(垂直排列的ViewGroup):
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {if (!changed) return; // 布局未变化时直接返回,避免重复计算int childCount = getChildCount();int parentLeft = getPaddingLeft(); // 父View的左内边距int parentTop = getPaddingTop(); // 父View的上内边距int parentRight = right - left - getPaddingRight(); // 父View的右边界(减去内边距)for (int i = 0; i < childCount; i++) {View child = getChildAt(i);if (child.getVisibility() == View.GONE) continue; // 跳过GONE的子View// 子View的测量宽高int childWidth = child.getMeasuredWidth();int childHeight = child.getMeasuredHeight();// 计算子View的坐标(垂直排列:每个子View靠上,宽度填满父View可用空间)int childLeft = parentLeft;int childTop = parentTop;int childRight = parentRight; // 子View宽度=父View可用宽度int childBottom = childTop + childHeight;// 摆放子Viewchild.layout(childLeft, childTop, childRight, childBottom);// 更新下一个子View的top坐标(累加当前子View的高度+间距)parentTop = childBottom + dp2px(10); // 子View之间间距10dp}}
4.4、onDraw方法的绘制流程是什么?Canvas的常用绘制API(绘制图形、文字、图片)有哪些?
onDraw的绘制流程按“背景→自身内容→子View→装饰”顺序执行,Canvas提供丰富的绘制API,核心分为图形、文字、图片三大类,配合Paint可实现各类UI效果。
onDraw的绘制流程(按执行顺序):
绘制背景(drawBackground(canvas)):优先绘制View的背景(android:background属性),由父类View自动处理,无需手动调用。
绘制自身内容(onDraw(canvas)):自定义View的核心绘制逻辑,在此通过Canvas绘制图形、文字、图片等。
绘制子View(dispatchDraw(canvas)):仅ViewGroup拥有,递归调用所有子View的onDraw(),完成子View绘制。
绘制装饰内容(onDrawForeground(canvas)):绘制滚动条、前景色、图标等装饰元素,在自身和子View绘制完成后执行。
Canvas常用绘制API:
一、绘制图形(核心API)
drawCircle(float cx, float cy, float radius, Paint paint):绘制圆形(参数:圆心x/y、半径、画笔)。
drawRect(float left, float top, float right, float bottom, Paint paint):绘制矩形(参数:矩形四顶点坐标、画笔)。
drawRoundRect(RectF rect, float rx, float ry, Paint paint):绘制圆角矩形(参数:矩形区域、x/y方向圆角半径、画笔)。
drawLine(float startX, float startY, float endX, float endY, Paint paint):绘制直线(参数:起点/终点坐标、画笔)。
drawPath(Path path, Paint paint):绘制不规则图形(参数:路径、画笔,可实现三角形、多边形等)。
二、绘制文字(核心API)
drawText(String text, float x, float y, Paint paint):绘制文字(参数:文本内容、基线x/y坐标、画笔)。
drawTextAlign(Paint.Align align):设置文字对齐方式(左对齐、居中、右对齐,需配合x坐标)。
drawTextSize(float textSize):设置文字大小(通过Paint设置,而非Canvas)。
三、绘制图片(核心API)
drawBitmap(Bitmap bitmap, float left, float top, Paint paint):绘制图片(参数:位图、左上角x/y坐标、画笔)。
drawBitmap(Bitmap bitmap, Rect src, RectF dst, Paint paint):裁剪+缩放绘制(参数:位图、原图裁剪区域、目标绘制区域、画笔)。
API使用示例:
protected void onDraw(Canvas canvas) {super.onDraw(canvas);Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);// 1. 绘制圆形(红色填充)paint.setColor(Color.RED);canvas.drawCircle(200, 200, 100, paint);// 2. 绘制文字(黑色、24sp、居中)paint.setColor(Color.BLACK);paint.setTextSize(dp2px(24));paint.setTextAlign(Paint.Align.CENTER);canvas.drawText("自定义View", 200, 200, paint); // y为基线坐标// 3. 绘制图片(从资源加载)Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_test);canvas.drawBitmap(bitmap, 50, 350, null);}
4.5、Paint的核心属性(颜色、笔触宽度、抗锯齿、填充模式等)如何设置?各自的作用是什么?
Paint是“画笔”工具,核心属性决定绘制的颜色、线条样式、填充规则等,需通过setXxx()方法配置,不同属性组合可实现多样化UI效果。
Paint核心属性及使用:
一、颜色(setColor(int color)/setARGB(int a, int r, int g, int b))
作用:设置绘制内容的颜色(支持RGB、ARGB格式,ARGB可控制透明度)。
示例:paint.setColor(Color.RED);(纯红色)、paint.setARGB(128, 255, 0, 0);(半透明红色)。
二、笔触宽度(setStrokeWidth(float width))
作用:设置线条/轮廓的宽度(仅对“描边模式”或线条类绘制生效)。
示例:paint.setStrokeWidth(5);(线条宽度5px),配合setStyle(Paint.Style.STROKE)使用。
三、抗锯齿(setAntiAlias(boolean aa)/构造时传Paint.ANTI_ALIAS_FLAG)
作用:消除绘制图形/文字的边缘锯齿,让边缘更平滑(会轻微损耗性能,建议默认开启)。
示例:Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);(构造时开启)。
四、填充模式(setStyle(Paint.Style style))
作用:设置绘制的填充规则,支持三种模式:
FILL:填充模式(默认,绘制图形内部)。
STROKE:描边模式(仅绘制图形轮廓)。
FILL_AND_STROKE:填充+描边模式。
示例:paint.setStyle(Paint.Style.STROKE);(绘制矩形轮廓)。
五、文字相关属性
setTextSize(float textSize):设置文字大小(单位为px,需手动转dp/sp)。
setTextAlign(Paint.Align align):设置文字对齐方式(LEFT/CENTER/RIGHT,基于drawText()的x坐标)。
setTypeface(Typeface typeface):设置字体样式(如粗体、斜体,Typeface.BOLD/Typeface.ITALIC)。
六、阴影效果(setShadowLayer(float radius, float dx, float dy, int color))
作用:为绘制内容添加阴影(参数:阴影模糊半径、x轴偏移、y轴偏移、阴影颜色)。
示例:paint.setShadowLayer(5, 3, 3, Color.GRAY);(灰色阴影,模糊5px,偏移3px)。
七、透明度(setAlpha(int alpha))
作用:设置整个画笔的透明度(0-255,0完全透明,255不透明),影响所有绘制内容。
示例:paint.setAlpha(180);(70%不透明度)。
4.6、如何实现自定义View的渐变效果(线性渐变、径向渐变、扫描渐变)?
渐变效果通过Shader(着色器)实现,Paint设置Shader后,绘制内容会呈现渐变颜色,核心分为线性、径向、扫描三种渐变,需通过对应的Shader子类配置参数。
三种渐变效果实现(结合Paint+Shader):
一、线性渐变(LinearGradient)
特点:从一个点到另一个点的线性颜色过渡(如水平、垂直、对角线渐变)。
构造参数:LinearGradient(float x0, float y0, float x1, float y1, int[] colors, float[] positions, Shader.TileMode tile)。
x0/y0:渐变起始点坐标;x1/y1:渐变结束点坐标。
colors:渐变颜色数组(如new int[]{Color.RED, Color.BLUE})。
positions:颜色过渡位置(null表示均匀过渡)。
tile:渐变区域外的填充模式(CLAMP/REPEAT/MIRROR)。
代码示例:
// 水平线性渐变(从左到右:红→蓝)LinearGradient linearGradient = new LinearGradient(0, 0, getWidth(), 0,new int[]{Color.RED, Color.BLUE},null, Shader.TileMode.CLAMP);paint.setShader(linearGradient);canvas.drawRect(0, 0, getWidth(), 200, paint);
二、径向渐变(RadialGradient)
特点:从圆心向外辐射的颜色过渡(如圆形渐变、光环效果)。
构造参数:RadialGradient(float cx, float cy, float radius, int[] colors, float[] positions, Shader.TileMode tile)。
cx/cy:渐变圆心坐标;radius:渐变半径。
代码示例:
// 径向渐变(圆心(200,200),半径100:红→黄→蓝)RadialGradient radialGradient = new RadialGradient(200, 200, 100,new int[]{Color.RED, Color.YELLOW, Color.BLUE},null, Shader.TileMode.CLAMP);paint.setShader(radialGradient);canvas.drawCircle(200, 200, 100, paint);
三、扫描渐变(SweepGradient)
特点:以圆心为中心,顺时针扫描式的颜色过渡(如雷达扫描、进度环效果)。
构造参数:SweepGradient(float cx, float cy, int[] colors, float[] positions)。
代码示例:
// 扫描渐变(圆心(200,200):红→绿→蓝)SweepGradient sweepGradient = new SweepGradient(200, 200,new int[]{Color.RED, Color.GREEN, Color.BLUE},null);paint.setShader(sweepGradient);canvas.drawCircle(200, 200, 100, paint);
注意事项:
Shader设置后,会作用于后续所有绘制内容,若需部分绘制使用渐变,需切换Paint的Shader(如paint.setShader(null)取消渐变)。
TileMode.CLAMP(默认):渐变区域外延伸最后一种颜色;REPEAT:重复渐变;MIRROR:镜像重复渐变。
4.7、如何处理自定义View的圆角、阴影效果?Canvas的clipPath方法有什么注意事项?
圆角效果可通过drawRoundRect、clipPath或Xfermode实现,阴影效果通过Paint.setShadowLayer()实现,clipPath需注意硬件加速兼容性和边缘锯齿问题。
圆角效果实现(三种方式):
一、直接绘制圆角矩形(drawRoundRect)
适用场景:简单圆角矩形UI(如按钮、卡片),无需裁剪内容。
代码示例:
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);paint.setColor(Color.RED);RectF rect = new RectF(50, 50, 350, 250);canvas.drawRoundRect(rect, 20, 20, paint); // 20dp圆角半径
二、裁剪画布(clipPath)
适用场景:复杂形状圆角(如圆形图片、不规则圆角),需裁剪View内所有内容(包括图片、文字)。
代码示例:
// 裁剪圆角矩形画布,后续绘制内容仅显示在裁剪区域内Path path = new Path();RectF rect = new RectF(50, 50, 350, 250);path.addRoundRect(rect, 20, 20, Path.Direction.CW);canvas.clipPath(path); // 裁剪画布// 绘制图片(仅圆角区域显示)Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_test);canvas.drawBitmap(bitmap, 50, 50, null);
三、图层混合(Xfermode)
适用场景:圆角+图片/复杂内容,需避免clipPath的锯齿问题。
代码示例:
// 开启离屏缓冲(避免图层混合污染)int layerId = canvas.saveLayer(0, 0, getWidth(), getHeight(), null);// 绘制目标圆角矩形(DST)Paint dstPaint = new Paint(Paint.ANTI_ALIAS_FLAG);dstPaint.setColor(Color.WHITE);RectF dstRect = new RectF(50, 50, 350, 250);canvas.drawRoundRect(dstRect, 20, 20, dstPaint);// 设置混合模式(仅显示DST和SRC的交集区域)Paint srcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);srcPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));// 绘制源图片(SRC)Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_test);canvas.drawBitmap(bitmap, 50, 50, srcPaint);// 恢复图层,关闭混合模式srcPaint.setXfermode(null);canvas.restoreToCount(layerId);
阴影效果实现:
核心API:Paint.setShadowLayer(float radius, float dx, float dy, int color)。
代码示例(圆角卡片+阴影):
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);paint.setColor(Color.WHITE);// 设置阴影(模糊半径10px,偏移3px,灰色)paint.setShadowLayer(10, 3, 3, Color.parseColor("#888888"));// 绘制圆角矩形(阴影会自动跟随形状)RectF rect = new RectF(50, 50, 350, 250);canvas.drawRoundRect(rect, 20, 20, paint);
注意:阴影效果需View有背景或绘制内容,且避免开启硬件加速(部分版本可能显示异常)。
clipPath的注意事项:
硬件加速兼容性:Android 4.0(API 14)以下不支持硬件加速下的clipPath,需关闭硬件加速(view.setLayerType(View.LAYER_TYPE_SOFTWARE, null))。
边缘锯齿:clipPath裁剪后的边缘可能出现锯齿,可通过开启抗锯齿(paint.setAntiAlias(true))或使用Xfermode替代。
裁剪范围:clipPath会裁剪整个画布,后续绘制的所有内容都会受影响,若需局部裁剪,需使用save()/restore()保存恢复画布状态。
4.8、自定义View中如何实现文字的居中绘制?如何处理文字的换行和字体样式?
文字居中需计算基线(baseline)坐标,结合文字的ascent/descent调整,换行需手动分割字符串并逐行绘制,字体样式通过Typeface配置。
文字居中绘制(水平+垂直居中):
核心原理:文字的绘制坐标y是基线(baseline)位置,需通过Paint的getTextBounds()或getFontMetrics()计算文字的上下偏移,实现垂直居中。
关键API:getTextBounds(String text, int start, int end, Rect bounds):获取文字的边界矩形(包含文字的宽高),getFontMetrics(Paint.FontMetrics fm):获取文字的度量信息(ascent/descent/top/bottom)。
代码示例(水平垂直居中):
protected void onDraw(Canvas canvas) {super.onDraw(canvas);String text = "居中文字";Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);paint.setTextSize(dp2px(24));// 1. 水平居中:设置文字对齐方式为CENTER,x坐标为容器中心paint.setTextAlign(Paint.Align.CENTER);int centerX = getWidth() / 2;// 2. 垂直居中:计算基线y坐标Rect textBounds = new Rect();paint.getTextBounds(text, 0, text.length(), textBounds); // 获取文字边界int textHeight = textBounds.bottom - textBounds.top; // 文字高度int centerY = getHeight() / 2 + textHeight / 2 - textBounds.bottom; // 基线y坐标// 绘制文字canvas.drawText(text, centerX, centerY, paint);}
文字换行处理:
核心逻辑:
计算单行文字的最大宽度(不超过View的可用宽度)。
按空格/标点分割字符串,逐行累加文字宽度,超出时换行。
记录每行文字的基线位置,逐行绘制。
代码示例(自动换行):
private void drawMultiLineText(Canvas canvas, String text, float x, float y, Paint paint, float maxWidth) {if (text == null || text.isEmpty()) return;int lineHeight = (int) (paint.getTextSize() * 1.5f); // 行高=文字大小*1.5(可自定义)String[] words = text.split(" "); // 按空格分割单词StringBuilder line = new StringBuilder();for (String word : words) {String temp = line + word + " ";float textWidth = paint.measureText(temp); // 计算临时行宽度if (textWidth <= maxWidth) {line.append(word).append(" "); // 未超宽,添加到当前行} else {// 超宽,绘制当前行并换行canvas.drawText(line.toString(), x, y, paint);line = new StringBuilder(word + " ");y += lineHeight; // 下移基线}}// 绘制最后一行canvas.drawText(line.toString(), x, y, paint);}// 调用示例(在onDraw中)float maxWidth = getWidth() - getPaddingLeft() - getPaddingRight(); // 最大可用宽度drawMultiLineText(canvas, "这是一段需要自动换行的长文本,测试自定义View的文字换行功能",getPaddingLeft(), getPaddingTop() + paint.getTextSize(), paint, maxWidth);
字体样式设置:
核心API:Paint.setTypeface(Typeface typeface),支持系统字体和自定义字体文件。
系统字体样式:
// 从assets目录加载字体文件(如"fonts/my_font.ttf")Typeface customTypeface = Typeface.createFromAsset(getContext().getAssets(), "fonts/my_font.ttf");paint.setTypeface(customTypeface);
五、事件处理类(实现自定义控件交互功能)
5.1、自定义View中如何处理触摸事件(点击、长按、滑动)?onTouchEvent的返回值有什么意义?
触摸事件通过MotionEvent的事件类型(DOWN/MOVE/UP)区分交互行为,点击需匹配DOWN+UP,长按需DOWN后延时判断,滑动需MOVE时计算坐标偏移,onTouchEvent返回true表示消费事件,false表示不消费并回传父View。
触摸事件核心类型:
ACTION_DOWN:手指按下(事件序列的起点,必须处理以接收后续事件)。
ACTION_MOVE:手指滑动(多次触发,需计算坐标差判断滑动方向)。
ACTION_UP:手指抬起(事件序列的终点,判断点击/滑动结束)。
ACTION_CANCEL:事件被拦截(需清理状态,如长按延时任务)。
各类交互的处理逻辑:
点击事件(短按):逻辑:ACTION_DOWN记录按下时间和坐标,ACTION_UP时判断按下→抬起的时间差(如<500ms)和坐标偏移(如<10px),满足则视为点击。
长按事件:逻辑:ACTION_DOWN时启动延时任务(如500ms),ACTION_UP/ACTION_CANCEL时取消任务,延时到期未取消则视为长按。
滑动事件:逻辑:ACTION_DOWN记录起始坐标(startX/startY),ACTION_MOVE时计算当前坐标与起始坐标的差值(dx/dy),根据差值判断滑动方向(水平/垂直)并更新View位置。
onTouchEvent返回值的意义:
true:当前View消费该事件,后续事件(如MOVE/UP)会继续传递给该View的onTouchEvent。
false:当前View不消费该事件,事件会回传给父View的onTouchEvent,直至被消费或返回Activity。
注意:若ACTION_DOWN返回false,后续MOVE/UP事件不会再传递给该View。
代码示例(处理点击、长按、滑动):
private static final long LONG_CLICK_DELAY = 500; // 长按延时private float startX, startY;private boolean isLongClick = false;private Handler longClickHandler = new Handler(Looper.getMainLooper());private Runnable longClickRunnable = () -> {isLongClick = true;Toast.makeText(getContext(), "长按触发", Toast.LENGTH_SHORT).show();};@Overridepublic boolean onTouchEvent(MotionEvent event) {switch (event.getAction()) {case MotionEvent.ACTION_DOWN:startX = event.getX();startY = event.getY();// 启动长按延时任务longClickHandler.postDelayed(longClickRunnable, LONG_CLICK_DELAY);return true; // 消费事件,接收后续MOVE/UPcase MotionEvent.ACTION_MOVE:float dx = event.getX() - startX;float dy = event.getY() - startY;// 滑动偏移超过10px,取消长按任务if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {longClickHandler.removeCallbacks(longClickRunnable);// 处理滑动:平移ViewsetTranslationX(getTranslationX() + dx);setTranslationY(getTranslationY() + dy);startX = event.getX();startY = event.getY();}break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:longClickHandler.removeCallbacks(longClickRunnable);// 未触发长按,且偏移量小,视为点击if (!isLongClick) {float upDx = event.getX() - startX;float upDy = event.getY() - startY;if (Math.abs(upDx) < 10 && Math.abs(upDy) < 10) {Toast.makeText(getContext(), "点击触发", Toast.LENGTH_SHORT).show();}}isLongClick = false;break;}return super.onTouchEvent(event);}
5.2、如何为自定义View添加点击事件监听器和长按事件监听器?与系统控件的事件监听有区别吗?
自定义View需手动定义监听器接口并暴露set方法,在onTouchEvent中触发回调,与系统控件的核心区别是“系统控件已封装事件判断逻辑,自定义需手动实现”,使用方式完全一致。
添加监听器的步骤:
一、定义监听器接口(点击/长按分别定义或合并)
// 点击监听器(参考系统View.OnClickListener)public interface OnCustomClickListener {void onClick(CustomView view);}// 长按监听器(参考系统View.OnLongClickListener)public interface OnCustomLongClickListener {boolean onLongClick(CustomView view); // 返回true表示消费长按,阻止点击触发}
二、声明监听器变量并暴露set方法
private OnCustomClickListener mClickListener;private OnCustomLongClickListener mLongClickListener;public void setOnCustomClickListener(OnCustomClickListener listener) {mClickListener = listener;}public void setOnCustomLongClickListener(OnCustomLongClickListener listener) {mLongClickListener = listener;}
三、在onTouchEvent中触发监听器
// ACTION_UP时触发点击(需排除长按)if (!isLongClick && mClickListener != null) {mClickListener.onClick(this);}// 长按延时到期时触发长按private Runnable longClickRunnable = () -> {isLongClick = true;boolean consume = false;if (mLongClickListener != null) {consume = mLongClickListener.onLongClick(this);}// 若长按被消费,后续点击不触发if (consume) {isLongClick = true;}};
与系统控件的区别:
相同点:使用方式一致(外部通过setXxxListener设置回调),长按监听器返回true可阻止点击触发,遵循系统设计规范。
不同点:
1、系统控件(如Button)已内部实现“点击/长按的事件判断逻辑”,无需手动处理MotionEvent。
2、自定义View需手动在onTouchEvent中判断事件类型、时间差、坐标偏移,再触发监听器。
简化方案:直接复用系统监听器接口(无需自定义),触发逻辑不变。
public void setOnClickListener(View.OnClickListener listener) {super.setOnClickListener(listener); // 或自定义触发逻辑}
5.3、自定义ViewGroup中onInterceptTouchEvent的作用是什么?如何通过它拦截子View的事件?
onInterceptTouchEvent是ViewGroup专属的“事件拦截开关”,作用是决定是否阻止事件传递给子View,返回true则拦截事件,交给自身onTouchEvent处理,返回false则事件继续传递给子View。
onInterceptTouchEvent的核心细节:
调用时机:在ViewGroup的dispatchTouchEvent中,事件传递给子View之前调用。
触发场景:仅当事件序列的ACTION_DOWN被当前ViewGroup接收后,后续MOVE/UP才会触发该方法。
默认行为:返回false(不拦截,事件传递给子View)。
特殊情况:若返回true拦截ACTION_DOWN,则后续MOVE/UP会直接交给ViewGroup的onTouchEvent,不再调用onInterceptTouchEvent。
拦截子View事件的实现步骤:
重写onInterceptTouchEvent,判断是否需要拦截(如根据滑动方向、子View状态)。
拦截逻辑:ACTION_DOWN时初始化状态,ACTION_MOVE时判断拦截条件,满足则返回true。
注意:拦截后需在自身onTouchEvent中处理事件,避免事件无响应。
代码示例(垂直滚动ViewGroup拦截水平滑动事件):
public class VerticalScrollViewGroup extends ViewGroup {private float startX, startY;private static final int SLIDE_THRESHOLD = 10; // 滑动判断阈值@Overridepublic boolean onInterceptTouchEvent(MotionEvent event) {switch (event.getAction()) {case MotionEvent.ACTION_DOWN:startX = event.getX();startY = event.getY();return false; // 不拦截DOWN,让子View接收case MotionEvent.ACTION_MOVE:float dx = event.getX() - startX;float dy = event.getY() - startY;// 垂直滑动偏移大于水平,拦截事件(自身处理垂直滚动)if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > SLIDE_THRESHOLD) {return true; // 拦截,事件交给自身onTouchEvent}break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:break;}return super.onInterceptTouchEvent(event);}@Overridepublic boolean onTouchEvent(MotionEvent event) {// 拦截后处理垂直滚动逻辑(如通过Scroller实现)switch (event.getAction()) {case MotionEvent.ACTION_MOVE:float dy = event.getY() - startY;// 处理滚动(示例:平移ViewGroup)scrollBy(0, (int) -dy);startY = event.getY();break;}return true;}}
5.4、什么是滑动冲突?自定义控件中常见的滑动冲突场景有哪些?
滑动冲突是“父View和子View对同一滑动事件(ACTION_MOVE)都有处理需求”,导致滑动行为异常(如父View滚动时子View不响应,或反之),常见场景集中在“同向滑动”和“交叉滑动”的嵌套结构。
滑动冲突的本质:事件传递过程中,父View和子View对滑动事件的“争夺”,两者都希望消费ACTION_MOVE事件,导致系统无法判断优先级,最终滑动效果不符合预期。
常见滑动冲突场景:
一、同向滑动冲突(父View和子View滑动方向一致)
场景一:ScrollView(垂直)嵌套ListView(垂直),滑动时不确定是滚动ListView还是ScrollView。
场景二:自定义垂直滚动容器嵌套RecyclerView(垂直),RecyclerView的滑动被父容器拦截或反之。
二、交叉滑动冲突(父View和子View滑动方向垂直)
场景一:ViewPager(水平)嵌套ScrollView(垂直),滑动时不确定是切换ViewPager页面还是滚动ScrollView。
场景二:自定义水平滑动容器嵌套垂直滑动控件(如垂直滚动的自定义View),水平滑动时触发子View的垂直滑动,或反之。
三、复杂嵌套冲突
场景:ViewPager嵌套RecyclerView(垂直),RecyclerView的Item又包含水平滑动控件(如HorizontalScrollView),多重滑动方向叠加导致冲突。
5.5、解决滑动冲突的核心思路是什么?外部拦截法和内部拦截法的实现步骤分别是什么?
解决滑动冲突的核心思路是“明确事件优先级”,通过判断滑动方向、子View状态(如是否滑动到边界),决定事件交给父View还是子View消费,主流方案分为外部拦截法(父View拦截)和内部拦截法(子View控制父View不拦截)。
核心判断依据:
滑动方向:如水平滑动交给父View(ViewPager),垂直滑动交给子View(ScrollView)。
子View边界状态:如子View(ListView)已滑动到顶部/底部,再滑动则交给父View(ScrollView)消费。
业务场景:如特定区域的滑动交给子View,其他区域交给父View。
外部拦截法(推荐,父View主导拦截):
核心逻辑:父View在onInterceptTouchEvent中判断是否拦截事件,符合条件则拦截,否则不拦截。
实现步骤:
1、父View重写onInterceptTouchEvent,ACTION_DOWN时初始化坐标,返回false(不拦截,让子View接收)。
2、ACTION_MOVE时,根据滑动方向/子View状态判断是否拦截:若事件应交给父View处理(如水平滑动),返回true拦截,若事件应交给子View处理(如垂直滑动),返回false不拦截。
3、ACTION_UP/ACTION_CANCEL时,重置状态,返回false。
代码示例(父View水平滑动,子View垂直滑动):
@Overridepublic boolean onInterceptTouchEvent(MotionEvent event) {boolean intercept = false;float x = event.getX();float y = event.getY();switch (event.getAction()) {case MotionEvent.ACTION_DOWN:intercept = false;startX = x;startY = y;break;case MotionEvent.ACTION_MOVE:float dx = x - startX;float dy = y - startY;// 水平滑动偏移大于垂直,父View拦截(处理水平滑动)if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SLIDE_THRESHOLD) {intercept = true;} else {intercept = false;}break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:intercept = false; // 不拦截UP,让子View接收break;}startX = x;startY = y;return intercept;}
内部拦截法(子View主导,控制父View不拦截):
核心逻辑:子View在dispatchTouchEvent中判断是否需要消费事件,若需要则通过requestDisallowInterceptTouchEvent(true)阻止父View拦截,否则允许父View拦截。
实现步骤:
1、父View默认不拦截ACTION_DOWN(onInterceptTouchEvent返回false)。
2、子View重写dispatchTouchEvent,ACTION_DOWN时调用parent.requestDisallowInterceptTouchEvent(true)(阻止父View拦截后续事件)。
3、ACTION_MOVE时,判断事件是否应由子View处理:若应由子View处理(如垂直滑动),保持requestDisallowInterceptTouchEvent(true),若应由父View处理(如水平滑动),调用requestDisallowInterceptTouchEvent(false)(允许父View拦截)。
4、ACTION_UP/ACTION_CANCEL时,重置requestDisallowInterceptTouchEvent(false)。
代码示例(子View垂直滑动,控制父View不拦截):
@Overridepublic boolean dispatchTouchEvent(MotionEvent event) {float x = event.getX();float y = event.getY();switch (event.getAction()) {case MotionEvent.ACTION_DOWN:// 阻止父View拦截后续事件getParent().requestDisallowInterceptTouchEvent(true);startX = x;startY = y;break;case MotionEvent.ACTION_MOVE:float dx = x - startX;float dy = y - startY;// 水平滑动偏移大于垂直,允许父View拦截(父View处理水平滑动)if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SLIDE_THRESHOLD) {getParent().requestDisallowInterceptTouchEvent(false);}break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:getParent().requestDisallowInterceptTouchEvent(false);break;}return super.dispatchTouchEvent(event);}
两种方法对比:
外部拦截法:逻辑简单,易于维护,推荐优先使用(父View统一控制事件分发)。
内部拦截法:灵活性高,适合子View状态复杂的场景(如子View需根据自身滚动位置决定是否允许父View拦截)。
5.6、如何实现自定义View的惯性滑动、弹性滑动效果?Scroller和OverScroller的区别是什么?
惯性滑动通过Scroller/OverScroller计算滚动轨迹,配合computeScroll实现,弹性滑动可通过OverScroller的过度滚动(fling带弹性参数)或ValueAnimator实现,两者核心区别是OverScroller支持弹性回弹,Scroller仅支持基础滚动。
惯性滑动实现(Scroller/OverScroller):
惯性滑动是“手指抬起后,View继续滑动一段距离并逐渐减速停止”,核心依赖fling方法计算滚动速度衰减。
核心步骤:
1、初始化Scroller/OverScroller(在View构造方法中)。
2、ACTION_UP时,通过VelocityTracker获取滑动速度(x/y方向)。
3、调用scroller.fling()传入速度、边界值,触发惯性滚动。
4、重写computeScroll,通过scroller.computeScrollOffset()判断滚动是否结束,更新View位置并重绘。
代码示例(惯性滑动):
private Scroller mScroller;private VelocityTracker mVelocityTracker;private static final int MAX_VELOCITY = 2000; // 最大滑动速度public CustomScrollView(Context context) {super(context);mScroller = new Scroller(context);mVelocityTracker = VelocityTracker.obtain();}public boolean onTouchEvent(MotionEvent event) {mVelocityTracker.addMovement(event); // 追踪速度switch (event.getAction()) {case MotionEvent.ACTION_UP:// 计算滑动速度(单位:像素/秒)mVelocityTracker.computeCurrentVelocity(1000, MAX_VELOCITY);int velocityX = (int) mVelocityTracker.getXVelocity();int velocityY = (int) mVelocityTracker.getYVelocity();// 触发惯性滚动(参数:起始位置、速度、最小/最大滚动范围)mScroller.fling(getScrollX(), getScrollY(), // 起始滚动位置-velocityX, -velocityY, // 滚动速度(负号调整方向)0, getWidth() - getMeasuredWidth(), // x方向边界0, getHeight() - getMeasuredHeight() // y方向边界);invalidate(); // 触发computeScrollmVelocityTracker.clear();break;}return true;}public void computeScroll() {super.computeScroll();// 判断滚动是否结束if (mScroller.computeScrollOffset()) {// 更新View滚动位置scrollTo(mScroller.getCurrX(), mScroller.getCurrY());postInvalidate(); // 继续滚动,重绘}}
弹性滑动实现(OverScroller):
弹性滑动是“滚动到边界后,超出边界一段距离再回弹”,需用OverScroller的fling或springBack方法。
代码示例(弹性回弹):
private OverScroller mOverScroller;// 滚动到边界后触发弹性回弹private void springBack() {// 回弹到x=0,y=0位置(参数:当前位置、目标位置、过度滚动范围)mOverScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, 0);invalidate();}// 或fling时支持过度滚动mOverScroller.fling(getScrollX(), getScrollY(),-velocityX, -velocityY,-200, getWidth() - getMeasuredWidth() + 200, // x方向允许过度滚动200px-200, getHeight() - getMeasuredHeight() + 200,50, 50 // 弹性回弹系数);
Scroller和OverScroller的区别:

5.7、自定义控件中如何识别手势(如双击、滑动、缩放、旋转)?GestureDetector的使用方法
复杂手势(双击、缩放、旋转)可通过系统提供的GestureDetector(滑动/双击)、ScaleGestureDetector(缩放)、RotateGestureDetector(旋转)实现,无需手动处理MotionEvent的复杂逻辑,GestureDetector的核心是绑定OnGestureListener接口,在回调中处理手势。
GestureDetector(处理滑动、双击、长按):
一、使用步骤
1、初始化GestureDetector,传入Context和OnGestureListener(或SimpleOnGestureListener,简化回调)。
2、在View的onTouchEvent中,将MotionEvent传递给gestureDetector.onTouchEvent(event)。
3、实现OnGestureListener的回调方法(如onFling(滑动)、onDoubleTap(双击))。
代码示例(GestureDetector识别滑动和双击):
private GestureDetector mGestureDetector;public CustomGestureView(Context context) {super(context);// 初始化,使用SimpleOnGestureListener(无需实现所有方法)mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {// 滑动手势(手指快速滑动后抬起)public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {float dx = e2.getX() - e1.getX();float dy = e2.getY() - e1.getY();// 水平滑动if (Math.abs(dx) > Math.abs(dy)) {Toast.makeText(getContext(), dx > 0 ? "向右滑动" : "向左滑动", Toast.LENGTH_SHORT).show();}return true;}// 双击手势public boolean onDoubleTap(MotionEvent e) {Toast.makeText(getContext(), "双击触发", Toast.LENGTH_SHORT).show();return true;}// 长按手势(可替代手动延时任务)public void onLongPress(MotionEvent e) {Toast.makeText(getContext(), "长按触发", Toast.LENGTH_SHORT).show();}});}public boolean onTouchEvent(MotionEvent event) {// 传递事件给GestureDetectorreturn mGestureDetector.onTouchEvent(event);}
ScaleGestureDetector(处理缩放):
一、使用步骤
1、初始化ScaleGestureDetector,传入Context和OnScaleGestureListener。
2、在onTouchEvent中传递MotionEvent,实现onScale(缩放中)、onScaleEnd(缩放结束)回调。
代码示例(缩放手势):
private ScaleGestureDetector mScaleDetector;private float mScaleFactor = 1.0f; // 缩放比例public CustomScaleView(Context context) {super(context);mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.OnScaleGestureListener() {public boolean onScale(ScaleGestureDetector detector) {// 获取缩放因子(detector.getScaleFactor() > 1 放大,<1 缩小)mScaleFactor *= detector.getScaleFactor();mScaleFactor = Math.max(0.5f, Math.min(mScaleFactor, 2.0f)); // 限制缩放范围invalidate(); // 重绘,应用缩放return true;}public boolean onScaleBegin(ScaleGestureDetector detector) {return true; // 返回true表示允许缩放}public void onScaleEnd(ScaleGestureDetector detector) {}});}public boolean onTouchEvent(MotionEvent event) {mScaleDetector.onTouchEvent(event);return true;}protected void onDraw(Canvas canvas) {super.onDraw(canvas);// 应用缩放canvas.save();canvas.scale(mScaleFactor, mScaleFactor, getWidth()/2, getHeight()/2); // 中心缩放// 绘制内容(如圆形)canvas.drawCircle(getWidth()/2, getHeight()/2, 100, mPaint);canvas.restore();}
RotateGestureDetector(处理旋转):核心逻辑与缩放类似,通过OnRotateGestureListener的onRotate回调获取旋转角度(detector.getRotationDegreesDelta()),更新View的旋转角度并重绘。
注意事项:多个手势检测器可同时使用(如GestureDetector+ScaleGestureDetector),在onTouchEvent中依次传递MotionEvent,手势回调的return true表示消费该手势,阻止后续回调触发,return false则允许其他手势处理器接收。

