大数跨境
0
0

Android自定义控件完全指南:绘制流程、事件处理、滑动冲突一网打尽(上)

Android自定义控件完全指南:绘制流程、事件处理、滑动冲突一网打尽(上) 图解Android开发
2025-11-25
1

在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); // 填充模式    }    @Override    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.CircleView    android: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添加颜色和半径属性):  

<?xml version="1.0" encoding="utf-8"?><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);    }    @Override    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);    }    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        // 1. 测量所有子View        int 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);    }    @Override    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+childHeight            child.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.CircleView   style="@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); // 关联circleThemeColor     mCircleColor = 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()保存最终宽高,不可遗漏(否则会抛出异常)。

代码示例:

@Overrideprotected 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); // 圆形半径(内容核心尺寸)@Overrideprotected 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):

@Overrideprotected 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;        // 摆放子View        child.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使用示例:

@Overrideprotected void onDraw(Canvas canvas) {    super.onDraw(canvas);    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);    // 1. 绘制圆形(红色填充)    paint.setColor(Color.RED);    canvas.drawCircle(200200100, paint);    // 2. 绘制文字(黑色、24sp、居中)    paint.setColor(Color.BLACK);    paint.setTextSize(dp2px(24));    paint.setTextAlign(Paint.Align.CENTER);    canvas.drawText("自定义View"200200, paint); // y为基线坐标    // 3. 绘制图片(从资源加载)    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_test);    canvas.drawBitmap(bitmap, 50350null);}

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(      00, getWidth(), 0,      new int[]{Color.RED, Color.BLUE},      null, Shader.TileMode.CLAMP);paint.setShader(linearGradient);canvas.drawRect(00, 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(      200200100,      new int[]{Color.RED, Color.YELLOW, Color.BLUE},      null, Shader.TileMode.CLAMP);paint.setShader(radialGradient);canvas.drawCircle(200200100, paint);

三、扫描渐变(SweepGradient)

特点:以圆心为中心,顺时针扫描式的颜色过渡(如雷达扫描、进度环效果)。

构造参数:SweepGradient(float cx, float cy, int[] colors, float[] positions)。

代码示例:

// 扫描渐变(圆心(200,200):红→绿→蓝)SweepGradient sweepGradient = new SweepGradient(         200200,         new int[]{Color.RED, Color.GREEN, Color.BLUE},         null);paint.setShader(sweepGradient);canvas.drawCircle(200200100, 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(5050350250);canvas.drawRoundRect(rect, 2020, paint); // 20dp圆角半径

二、裁剪画布(clipPath)

适用场景:复杂形状圆角(如圆形图片、不规则圆角),需裁剪View内所有内容(包括图片、文字)。

代码示例:

// 裁剪圆角矩形画布,后续绘制内容仅显示在裁剪区域内Path path = new Path();RectF rect = new RectF(5050350250);path.addRoundRect(rect, 2020, Path.Direction.CW);canvas.clipPath(path); // 裁剪画布// 绘制图片(仅圆角区域显示)Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_test);canvas.drawBitmap(bitmap, 5050null);

三、图层混合(Xfermode)

适用场景:圆角+图片/复杂内容,需避免clipPath的锯齿问题。

代码示例:

// 开启离屏缓冲(避免图层混合污染)int layerId = canvas.saveLayer(00, getWidth(), getHeight(), null);// 绘制目标圆角矩形(DST)Paint dstPaint = new Paint(Paint.ANTI_ALIAS_FLAG);dstPaint.setColor(Color.WHITE);RectF dstRect = new RectF(5050350250);canvas.drawRoundRect(dstRect, 2020, 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, 5050, 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(1033, Color.parseColor("#888888"));// 绘制圆角矩形(阴影会自动跟随形状)RectF rect = new RectF(5050350250);canvas.drawRoundRect(rect, 2020, 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)。

代码示例(水平垂直居中):

@Overrideprotected 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),支持系统字体和自定义字体文件。

系统字体样式:

粗体:paint.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD))。
斜体:paint.setTypeface(Typeface.defaultFromStyle(Typeface.ITALIC))。
粗斜体:paint.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD_ITALIC))。
自定义字体(从assets加载):
// 从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/UP        case 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);                // 处理滑动:平移View                setTranslationX(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// 滑动判断阈值    @Override    public 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);    }    @Override    public 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();}@Overridepublic 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(); // 触发computeScroll            mVelocityTracker.clear();            break;    }    return true;}@Overridepublic 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(), 0000);    invalidate();}// 或fling时支持过度滚动  mOverScroller.fling(        getScrollX(), getScrollY(),        -velocityX, -velocityY,        -200, getWidth() - getMeasuredWidth() + 200// x方向允许过度滚动200px        -200, getHeight() - getMeasuredHeight() + 200,        5050 // 弹性回弹系数        );

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() {        // 滑动手势(手指快速滑动后抬起)        @Override        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;        }        // 双击手势        @Override        public boolean onDoubleTap(MotionEvent e) {            Toast.makeText(getContext(), "双击触发"Toast.LENGTH_SHORT).show();            return true;        }        // 长按手势(可替代手动延时任务)        @Override        public void onLongPress(MotionEvent e) {            Toast.makeText(getContext(), "长按触发"Toast.LENGTH_SHORT).show();        }    });}@Overridepublic boolean onTouchEvent(MotionEvent event) {    // 传递事件给GestureDetector    return 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() {        @Override        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;        }        @Override        public boolean onScaleBegin(ScaleGestureDetector detector) {            return true// 返回true表示允许缩放        }        @Override        public void onScaleEnd(ScaleGestureDetector detector) {        }    });}@Overridepublic boolean onTouchEvent(MotionEvent event) {    mScaleDetector.onTouchEvent(event);    return true;}@Overrideprotected void onDraw(Canvas canvas) {    super.onDraw(canvas);    // 应用缩放    canvas.save();    canvas.scale(mScaleFactor, mScaleFactor, getWidth()/2getHeight()/2); // 中心缩放    // 绘制内容(如圆形)    canvas.drawCircle(getWidth()/2getHeight()/2100, mPaint);    canvas.restore();}

RotateGestureDetector(处理旋转):核心逻辑与缩放类似,通过OnRotateGestureListener的onRotate回调获取旋转角度(detector.getRotationDegreesDelta()),更新View的旋转角度并重绘。

注意事项:多个手势检测器可同时使用(如GestureDetector+ScaleGestureDetector),在onTouchEvent中依次传递MotionEvent,手势回调的return true表示消费该手势,阻止后续回调触发,return false则允许其他手势处理器接收。

【声明】内容源于网络
0
0
图解Android开发
该公众号精心绘制安卓开发知识总结图谱,将零散的知识点串联起来,形成完整的知识体系,无论是新手搭建知识框架,还是老手查漏补缺,这些图谱都能成为你学习路上的得力助手,帮助你快速定位重点、难点,提升学习效率。
内容 21
粉丝 0
图解Android开发 该公众号精心绘制安卓开发知识总结图谱,将零散的知识点串联起来,形成完整的知识体系,无论是新手搭建知识框架,还是老手查漏补缺,这些图谱都能成为你学习路上的得力助手,帮助你快速定位重点、难点,提升学习效率。
总阅读113
粉丝0
内容21