前言
前面课程使用现成的系统组件;当普通Compose UI无法绘制复杂图形时,需要Canvas画布;同时配合手势检测,接收手指滑动。
本节课学习drawBehind修饰器、Canvas组件;Path路径;pointerInput手势;实现简易画板,可以手指画线、清空画布。
知识点清单
- drawBehind 后台绘制修饰器 与 Canvas组件的区别
- Canvas画布基础坐标;原点(0,0)是左上角
- Path路径,记录手指滑动路径;MutableState保存绘制路径
- 手势pointerInput、detectDragGestures手指滑动;
- 简易画板Demo;保存多条线条;清空画板
完整示例源码
importandroidx.compose.foundation.Canvasimportandroidx.compose.foundation.gestures.detectDragGesturesimportandroidx.compose.foundation.layout.Arrangementimportandroidx.compose.foundation.layout.Columnimportandroidx.compose.foundation.layout.fillMaxSizeimportandroidx.compose.foundation.layout.fillMaxWidthimportandroidx.compose.foundation.layout.paddingimportandroidx.compose.material3.Buttonimportandroidx.compose.material3.Textimportandroidx.compose.runtime.Composableimportandroidx.compose.runtime.MutableStateimportandroidx.compose.runtime.mutableStateListOfimportandroidx.compose.runtime.rememberimportandroidx.compose.runtime.setValueimportandroidx.compose.runtime.getValueimportandroidx.compose.ui.Modifierimportandroidx.compose.ui.geometry.Offsetimportandroidx.compose.ui.graphics.Colorimportandroidx.compose.ui.graphics.Pathimportandroidx.compose.ui.input.pointer.pointerInputimportandroidx.compose.ui.unit.dp//一条笔迹数据,保存一条Path以及画笔颜色dataclassDrawPathItem(valpath:Path,valcolor:Color)@ComposablefunSimpleDrawingBoardScreen(){//保存全部笔迹,多条路径valpathList=remember{mutableStateListOf<DrawPathItem>()}//当前正在画的这条路径varcurrentPath:Path?=remember{null}Column(modifier=Modifier.fillMaxSize().padding(12.dp)){Button(modifier=Modifier.fillMaxWidth(),onClick={pathList.clear()currentPath=null}){Text("🗑清空画板")}Canvas(modifier=Modifier.fillMaxSize().weight(1f).pointerInput(Unit){detectDragGestures(onDragStart={offset->//手指按下,新建一条路径,移动到按下的坐标currentPath=Path().apply{moveTo(offset.x,offset.y)}},onDrag={change,dragAmount->//手指拖动,追加线条change.consume()currentPath?.lineTo(change.position.x,change.position.y)},onDragEnd={//手指抬起,结束本条笔迹,加入列表保存currentPath?.let{p->pathList.add(DrawPathItem(path=p,color=Color.Blue))}currentPath=null})}){//先绘制已经完成保存的笔迹pathList.forEach{item->drawPath(path=item.path,color=item.color,strokeWidth=4f)}//绘制当前正在拖动的临时笔迹currentPath?.let{drawPath(path=it,color=Color.Blue,strokeWidth=4f)}}}}逐行解读
- drawBehind vs Canvas
- Modifier.drawBehind{} :在现有组件的底层绘制;适合给现有UI补充简单图形;组件本身仍然可以放置子Compose元素。
- Canvas{} :专门的画布组件;Canvas花括号内部是绘制作用域,不能直接写Compose组件;只能调用底层绘制API(drawLine、drawRect、drawPath、drawCircle)。
画布坐标系:左上角是原点 Offset(0,0) ;X向右增大;Y向下增大。
- Path路径对象
moveTo(x,y) :移动画笔到起点(不画线)
lineTo(x,y) :从上一个点连线到当前坐标;持续拖动就不断追加lineTo,形成连续笔迹。
Path是可变对象!不要直接放在状态里面;本Demo策略:抬起手指之后复制保存到stateList。
- 手势 detectDragGestures
pointerInput(Unit) 开启手势输入域;
- onDragStart:手指初次按下;创建新Path,moveTo按下点;
- onDrag:手指持续滑动;持续调用lineTo追加路径; change.consume() 消费手势事件;防止下层组件同时响应触摸;
- onDragEnd:手指抬起;把这条完成的笔迹加入 mutableStateListOf 列表;之后Canvas重组就会绘制这条笔迹。
- 状态保存逻辑
mutableStateListOf() 用来存放已经完成的笔迹;
currentPath 保存正在画还没有结束的临时笔迹;
当点击清空按钮直接clear整个列表,画布就全部擦除。 - 注意内存与重建
remember保存笔迹;屏幕旋转Activity重建画板上绘制的线条会全部消失!
如果希望旋转之后保留手绘笔迹:需要把笔迹数据上移到ViewModel;复杂场景可以序列化为点集合保存。
容易踩坑
- ❌ Path是可变对象;直接修改存进状态,Compose无法感知变化,界面不会自动重绘;抬起之后把完成的笔迹加入stateList。
- ❌ Canvas绘制代码里面写Compose Text、Button;Canvas的绘制lambda只能调用底层绘图函数,不能放可组合项。
- ❌ 忘记 change.consume() ;手势事件向下穿透,导致下层控件意外响应点击。
- ❌ 使用remember保存手绘笔迹;屏幕旋转绘制内容丢失;正式项目笔迹数据迁移到ViewModel。
- ✅ detectDragGestures监听拖拽手势;Path构建线条;mutableStateList保存已经完成笔迹。
多元化习题(练习)
习题1【基础‑改错题】
直接把Path对象放在remember可变状态中,拖动的时候直接修改同一个Path,为什么画布不会实时刷新?
答案:
Path属于可变类;修改对象内部属性不会触发Compose重组;Compose只能感知状态变量本身引用的变更;应当新建Path对象,修改完成之后加入状态列表。
解读:
Compose依靠引用变化触发重组,可变对象内部改动不会被追踪。
习题2【基础‑代码修改题】
修改画板示例,默认画笔颜色改为红色(Color.Red)。
答案:
pathList.add(DrawPathItem(path=p,color=Color.Red))同时临时绘制的那一处也修改color=Color.Red。
解读:
绘制的时候传入color;后续可以扩展画笔颜色选择器。
习题3【进阶‑补充代码题】
简述drawBehind修饰器和独立Canvas组件的适用场景差异。
答案:
drawBehind:给已有Compose组件底层补充绘制图形,该组件依然可以拥有子元素;
Canvas独立画布:专门用来大面积自定义绘图;绘制域内部不能放置Compose子组件;适合画板、图表。
解读:
简单装饰用drawBehind;大面积绘图优先Canvas。
习题4【综合‑逻辑应用题】
当前Demo的手绘笔迹使用remember保存;屏幕旋转之后线条消失;如何解决?
答案:
把 mutableStateListOf() 上移至ViewModel当中;ViewModel抗Activity重建;屏幕旋转笔迹保留;如果希望App杀掉进程之后依然保留手绘,需要序列化点集合写入Room数据库持久化。
解读:
短期抗旋转放ViewModel;长期持久化走数据库。
习题5【概念简答题】
detectDragGestures里面调用change.consume()作用是什么?
答案:
消费本次触摸手势事件;阻止触摸事件继续向下传递到下层UI;避免底层的组件同时响应点击、滑动。
解读:
画板画布上绘图的时候,不希望底层控件接收触摸。
本节课知识点总结
- Canvas画布自定义绘图;画布坐标原点位于左上角;X向右,Y向下;区分Canvas组件与drawBehind修饰器。
- Path路径:moveTo设置起点,lineTo追加线条;Path是可变对象;直接修改不会触发重组。
- pointerInput + detectDragGestures监听手指按下、拖动、抬起;consume消费触摸事件。
- 使用mutableStateList保存多条已经完成笔迹;currentPath临时保存正在绘制的线条;实现简易画板、清空功能。
- remember保存的笔迹屏幕旋转丢失;想要保留则迁移ViewModel;长期保存笔迹存入Room。
下一课预告:第17课 Compose动画基础;animate*AsState、Transition;页面元素显隐动画