鸿蒙开发入门指南:鸿蒙canvas实操——快速掌握自定义图表组件
鸿蒙开发入门指南:鸿蒙canvas实操——快速掌握自定义图表组件
- 一、为什么需要自己画图表?
- 二、自定义图表引擎:CanvasController 的核心实现
- 2.1 把数据转换成坐标点
- 2.2 贝塞尔曲线平滑连接
- 2.3 绘制点和标签
- 三、完成基础天气卡片:列表 + 曲线 + 交互
- 四、15天预报卡片:双曲线叠加
- 总结
大家好,我是木斯佳。今天聊一个实际的问题:在鸿蒙上想画个折线图,结果发现图表库少得可怜。不像前端有 ECharts、AntV、Highcharts 随便挑,如果有自定义需求,鸿蒙生态在这块基本靠自力更生。
那怎么办?自己画呗。正好我最近研究了一个鸿蒙天气组件的实现,里面有一套非常完整的 Canvas 曲线绘制方案。这篇文章就拆开给大家看看,怎么从零到一封装一个可复用的天气图表组件。
一、为什么需要自己画图表?
先说说背景。目前鸿蒙生态里专门做图表的库确实不多,就算有,也不一定适配你的业务场景。比如天气应用里的温度曲线,需求其实挺明确的:
- 横轴是时间(小时/天)
- 纵轴是温度值
- 需要平滑曲线
- 支持点击显示具体数值
- 多曲线叠加(最高温/最低温)
这种需求,与其找第三方库凑合,不如自己封装一套。而且封装好了,空气质量趋势、股票行情、用电数据都能复用。
在开始写代码之前,先理清楚架构。这个天气组件把图表能力分成了三层:
业务页面(Home) ↓ 图表组件(UIHours / UIDays) ↓ 绘图引擎(CustomCanvas + CanvasController)- 业务页面:只管传入数据和布局
- 图表组件:负责组装列表和曲线,处理点击交互
- 绘图引擎:负责所有 Canvas 绘制逻辑(坐标转换、贝塞尔曲线、标签渲染)
这样分层的好处是:绘图引擎写好之后,24小时天气和15天预报都能复用,不用写两遍绘制代码。
二、自定义图表引擎:CanvasController 的核心实现
先看最底层的CanvasController,它是整个图表能力的核心。
2.1 把数据转换成坐标点
温度数据是数值,画到 Canvas 上需要转成坐标。核心算法如下:
publicgenerateTemperaturePoints(temperatureArray:number[],intervalLength:number,// 每个点的间距canvasHeight:number,minTemperature:number,maxTemperature:number,):PointWithTemp[]{constpoints:PointWithTemp[]=[];consttemperatureRange=maxTemperature-minTemperature;for(leti=0;i<temperatureArray.length;i++){// X 坐标:间隔中点位置constx=intervalLength/2+i*intervalLength;// Y 坐标:根据温度值映射到 Canvas 高度constnormalizedValue=(temperatureArray[i]-minTemperature)/temperatureRange;consty=canvasHeight-normalizedValue*canvasHeight;points.push({x,y,temp:temperatureArray[i]});}returnpoints;}逻辑不复杂:X 轴等距分布,Y 轴按温度比例计算。minTemperature和maxTemperature是为了保证不同温度范围都能正确映射到 Canvas 高度上。
2.2 贝塞尔曲线平滑连接
如果直接把点连成折线,效果太生硬。这里用了贝塞尔曲线做平滑处理:
private_drawCurve(ctx:CanvasRenderingContext2D,temps:PointWithTemp[]):void{// 计算贝塞尔控制点constbezierControls=this._calcBezierControls(extendedPoints[i-1],extendedPoints[i],extendedPoints[i+1],extendedPoints[i+2],);ctx.bezierCurveTo(bezierControls.C1.x,bezierControls.C1.y,bezierControls.C2.x,bezierControls.C2.y,extendedPoints[i+1].x,extendedPoints[i+1].y,);}贝塞尔曲线的核心思想是:用前后两个点的位置来计算当前段的控制点,让曲线经过每个数据点,同时保持平滑。具体算法这里不展开,感兴趣的可以去看_calcBezierControls的实现。
2.3 绘制点和标签
曲线画完之后,还要在每个数据点上画圆点,并标出温度值:
private_drawPointsAndLabels(ctx:CanvasRenderingContext2D,temps:PointWithTemp[]):void{for(constpointoftemps){// 画圆点ctx.beginPath();ctx.arc(point.x,point.y,this._holeRadius,0,Math.PI*2);ctx.fill();// 画标签if(this._labelPosition!=='none'){constlabelText=Math.round(point.temp)+'°';constlabelY=this._labelPosition==='top'?point.y-20:point.y+20;ctx.fillText(labelText,point.x,labelY);}}}labelPosition参数很实用:最高温曲线标签在上方,最低温在下方,避免重叠。
三、完成基础天气卡片:列表 + 曲线 + 交互
CanvasController只管绘图逻辑,那谁来触发绘制?答案是CustomCanvas组件:
@ComponentV2exportstruct CustomCanvas{@Require@Paramcontroller:CanvasController;@Require@Paramlist:number[];@Paramindex:number=0;privatecontext:CanvasRenderingContext2D|null=null;@Monitor('list')drawAgainWhenListChange(){this.points=this.controller.generateTemperaturePoints(this.list,this.itemWidth,this.canvasHeight,this.min,this.max);this.controller.draw(this.context!,this.points,this.index);}@Monitor('index')drawAgainWhenIndexChange(){this.controller.draw(this.context!,this.points,this.index);}build(){Canvas(this.context).width('100%').height(this.canvasHeight).onReady(()=>{this.context=this.context;this.drawAgainWhenListChange();})}}这个组件做了三件事:
- 监听
list变化,数据变了就重新计算坐标点并重绘 - 监听
index变化,选中项变了就重绘高亮效果 - 通过
@Monitor装饰器自动响应变化,不用手动调用
前端视角:这有点像 React 的useEffect,依赖变了就重新执行。
有了底层绘图能力,上层组件就好写了。UIHours的布局很有意思:
Scroll(){Stack(){// 1. 温度曲线(底层)CustomCanvas({controller:this.controller,list:this.temps,max:this.maxTemp,min:this.minTemp,index:this.curIndex,})// 2. 列表覆盖层(上层)List(){ForEach(this.weathers,(item,index)=>{this.itemBuilder(index)})}// 3. 选中提示气泡if(this.showText){Text(this.showText).position({left:this.curIndex*this.itemWidth,top:曲线位置计算,})}}}注意这里的层级:曲线在最底下,列表在上层透明覆盖。用户点击列表的某一项时,更新curIndex,然后:
- 曲线重新绘制,高亮对应的数据点
- 气泡移动到对应位置
- 列表项本身也高亮
这种「数据驱动 + 分层绘制」的方式,比把所有逻辑塞在一起清晰得多。
四、15天预报卡片:双曲线叠加
15天预报比24小时复杂一点,因为要同时显示最高温和最低温两条曲线。
实现方式很简单:叠两个CustomCanvas:
Stack(){// 最高温曲线(标签在上方)CustomCanvas({controller:this.maxController,list:this.maxTemps,labelPosition:'top',curveColor:'#FBB460'})// 最低温曲线(标签在下方)CustomCanvas({controller:this.minController,list:this.minTemps,labelPosition:'bottom',curveColor:'#3F7EF7'})// 列表覆盖层List(){...}}两个 Canvas 共用同一套 X 轴坐标,通过不同的labelPosition和curveColor区分。第一列「昨天」还用虚线做了视觉弱化,细节处理得不错。
所有天气卡片都包在CardContainer里:
@ComponentV2exportstruct CardContainer{@Paramtitle:string='';@BuilderParamcontent:()=>void;build(){Column(){Text(this.title).fontSize(18).fontWeight(FontWeight.Bold).height(56).margin({left:16})if(this.content){this.content()}}.width('100%').backgroundColor($r('sys.color.comp_background_primary'))}}使用的时候:
CardContainer({title:'24小时天气预报'}){UIHours({weathers:this.vm.hourlyWeathers})}这个小封装虽然简单,但保证了所有卡片标题样式一致,后续加新卡片也只需套一层。
数据变化时,图表要自动重绘。这个组件通过HomeVM来管理状态:
@ObservedV2exportclassHomeVM{@TracehourlyWeathers:HourlyWeather[]=[];@TracedailyWeathers:DailyWeather[]=[];@Monitor('location')private_change(){WeatherUtils.getHourlyWeathers(this.location.code).then(res=>this.hourlyWeathers=res);WeatherUtils.getDailyWeathers(this.location.code).then(res=>this.dailyWeathers=res);}}@Monitor装饰器监听location变化,位置变了就重新拉数据。数据更新后,@Trace会自动触发 UI 刷新,CustomCanvas里的@Monitor('list')又会自动重绘曲线。
整个链路是:位置变化 → 数据拉取 → 列表更新 → 曲线重绘,全程自动化。
实际开发中需要注意:
1、CanvasController配置不同的颜色、标签位置、虚线样式,就能适配 24 小时和 15 天两种场景,不用写两遍。
2、选中项、数据列表、曲线重绘全部通过状态变化触发,不需要手动调用绘制方法。
3、鸿蒙的@Monitor装饰器非常实用——依赖变了就自动执行逻辑,省去手动调用的麻烦。
总结
这个天气组件的实现给了我们一个很好的范本:
CanvasController封装绘图算法(坐标转换、贝塞尔曲线、标签渲染)CustomCanvas桥接数据和 Canvas 生命周期UIHours/UIDays组装列表和曲线,处理交互CardContainer统一视觉风格
这套分层不止能做天气曲线,空气质量趋势、股价走势、用电数据监控,换换数据和样式就能复用。
如果你正在鸿蒙上做类似的可视化需求,不妨参考这个思路:把绘图能力抽成独立引擎,上层组件只负责数据映射和交互。这样既解决了图表库缺失的问题,也给自己攒了一套可复用的轮子。
