HarmonyOS PC实战之Flex 柱状图——用 alignItems.End 让双柱从底部对齐
2026/6/16 0:03:03 网站建设 项目流程

文章目录

    • 前言
      • alignItems.End 为什么能让柱子底部对齐
      • 柱子高度的动态计算
      • 完整代码
      • Y轴网格线的实现
      • 小结

前言

数据可视化是 PC 端应用的常见需求,但大多数场景不需要引入专业图表库——几根柱子、一条折线,完全可以用 Flex 布局手写,性能更好,样式完全可控。

这篇做一个收支柱状图:每组有两根柱子(收入 + 支出),高度根据数据比例动态计算。柱子高低不一,关键问题是:怎么让它们都从底部"站"在同一条基准线上?答案是alignItems: ItemAlign.End

alignItems.End 为什么能让柱子底部对齐

Flex 容器默认的alignItemsItemAlign.Center——所有子项垂直居中对齐。对于高度不同的柱子,居中对齐意味着顶部和底部都参差不齐。

ItemAlign.End让所有子项靠底部对齐。外层容器固定高度,所有柱子都"站"在容器底部,高的柱子向上伸,矮的柱子靠底,这正是我们想要的效果:

Row(){// 收入柱Column().width(20).height(incomeHeight)// 动态高度.backgroundColor('#10B981').borderRadius({topLeft:4,topRight:4})// 支出柱Column().width(20).height(expenseHeight)// 动态高度.backgroundColor('#EF4444').borderRadius({topLeft:4,topRight:4})}.height(160)// ← 固定容器高度.alignItems(VerticalAlign.Bottom)// ← 底部对齐.space(4)

柱子高度的动态计算

柱子高度 = 容器高度 × (当月数值 / 最大值)。先找出所有月份中的最大值,再按比例缩放:

constmaxValue=Math.max(...this.chartData.map(d=>Math.max(d.income,d.expense)))constbarHeight=(value:number)=>(value/maxValue)*this.chartHeight

最大值的柱子占满容器高度,其他柱子按比例缩短。

完整代码

interfaceMonthData{month:stringincome:numberexpense:number}@Entry@Componentstruct PcBarChartPage{@StateselectedMonth:number=-1@StatechartHeight:number=160chartData:MonthData[]=[{month:'1月',income:12800,expense:8600},{month:'2月',income:9200,expense:11400},{month:'3月',income:15600,expense:9800},{month:'4月',income:13400,expense:10200},{month:'5月',income:18200,expense:12600},{month:'6月',income:16800,expense:13800},{month:'7月',income:14200,expense:9400},{month:'8月',income:19600,expense:15200},{month:'9月',income:17400,expense:11600},{month:'10月',income:21000,expense:14800},{month:'11月',income:16400,expense:12400},{month:'12月',income:22800,expense:18600},]getmaxValue():number{returnMath.max(...this.chartData.map(d=>Math.max(d.income,d.expense)))}gettotalIncome():number{returnthis.chartData.reduce((sum,d)=>sum+d.income,0)}gettotalExpense():number{returnthis.chartData.reduce((sum,d)=>sum+d.expense,0)}getnetBalance():number{returnthis.totalIncome-this.totalExpense}barHeight(value:number):number{returnMath.max(4,Math.floor((value/this.maxValue)*this.chartHeight))}formatMoney(amount:number):string{return`¥${(amount/10000).toFixed(1)}`}@BuildersummaryCard(label:string,value:number,color:string,icon:string){Column({space:4}){Row({space:6}){Text(icon).fontSize(16)Text(label).fontSize(12).fontColor('#6B7280')}Text(this.formatMoney(value)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(color)}.padding({left:16,right:16,top:14,bottom:14}).backgroundColor(Color.White).borderRadius(12).shadow({radius:6,color:'#08000000',offsetY:2}).layoutWeight(1).alignItems(HorizontalAlign.Start)}@BuilderbarGroup(data:MonthData,index:number){Column({space:6}){// 选中时显示数值 tooltipif(this.selectedMonth===index){Column({space:2}){Text(`${this.formatMoney(data.income)}`).fontSize(9).fontColor('#10B981')Text(`${this.formatMoney(data.expense)}`).fontSize(9).fontColor('#EF4444')}.padding({left:4,right:4,top:3,bottom:3}).backgroundColor('#111827E6').borderRadius(4).margin({bottom:4})}// 柱子组(底部对齐的关键)Row({space:4}){// 收入柱Column().width(12).height(this.barHeight(data.income)).backgroundColor(this.selectedMonth===index?'#059669':'#10B981').borderRadius({topLeft:3,topRight:3}).animation({duration:300})// 支出柱Column().width(12).height(this.barHeight(data.expense)).backgroundColor(this.selectedMonth===index?'#DC2626':'#EF4444').borderRadius({topLeft:3,topRight:3}).animation({duration:300})}.height(this.chartHeight).alignItems(VerticalAlign.Bottom)// ← 核心:底部对齐// X轴月份标签Text(data.month).fontSize(10).fontColor(this.selectedMonth===index?'#111827':'#9CA3AF').fontWeight(this.selectedMonth===index?FontWeight.Medium:FontWeight.Normal)}.alignItems(HorizontalAlign.Center).layoutWeight(1).onClick(()=>{this.selectedMonth=this.selectedMonth===index?-1:index})}build(){Scroll(){Column({space:20}){// 标题Column({space:4}){Text('年度收支总览').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#111827')Text('2024年全年财务数据').fontSize(14).fontColor('#6B7280')}.alignItems(HorizontalAlign.Start).width('100%')// 汇总卡片Row({space:12}){this.summaryCard('总收入',this.totalIncome,'#10B981','📈')this.summaryCard('总支出',this.totalExpense,'#EF4444','📉')this.summaryCard('净结余',this.netBalance,this.netBalance>=0?'#3B82F6':'#F59E0B','💰')}.width('100%')// 图表区域Column({space:12}){Row(){Text('月度收支对比').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#374151').layoutWeight(1)// 图例Row({space:16}){Row({space:4}){Row().width(10).height(10).borderRadius(2).backgroundColor('#10B981')Text('收入').fontSize(11).fontColor('#6B7280')}Row({space:4}){Row().width(10).height(10).borderRadius(2).backgroundColor('#EF4444')Text('支出').fontSize(11).fontColor('#6B7280')}}}.width('100%')// Y轴参考线 + 柱状图Stack({alignContent:Alignment.BottomStart}){// Y轴网格线Column(){ForEach([1.0,0.75,0.5,0.25,0],(ratio:number)=>{Row(){Text(this.formatMoney(this.maxValue*ratio)).fontSize(9).fontColor('#D1D5DB').width(40).textAlign(TextAlign.End)Divider().layoutWeight(1).strokeWidth(1).color(ratio===0?'#9CA3AF':'#F3F4F6')}.width('100%').alignItems(VerticalAlign.Center)if(ratio>0){Blank().layoutWeight(1)}})}.width('100%').height(this.chartHeight+24).justifyContent(FlexAlign.SpaceBetween)// 柱状图(叠在网格线上)Row({space:0}){ForEach(this.chartData,(data:MonthData,index:number)=>{this.barGroup(data,index)})}.width('100%').padding({left:44})}.width('100%')}.padding(20).backgroundColor(Color.White).borderRadius(16).shadow({radius:8,color:'#08000000'})// 选中月份详情if(this.selectedMonth>=0){Row({space:16}){Column({space:4}){Text(this.chartData[this.selectedMonth].month+'收入').fontSize(12).fontColor('#6B7280')Text(this.formatMoney(this.chartData[this.selectedMonth].income)).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#10B981')}.layoutWeight(1).alignItems(HorizontalAlign.Center)Divider().vertical(true).height(40).strokeWidth(1).color('#E5E7EB')Column({space:4}){Text(this.chartData[this.selectedMonth].month+'支出').fontSize(12).fontColor('#6B7280')Text(this.formatMoney(this.chartData[this.selectedMonth].expense)).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#EF4444')}.layoutWeight(1).alignItems(HorizontalAlign.Center)Divider().vertical(true).height(40).strokeWidth(1).color('#E5E7EB')Column({space:4}){Text('结余').fontSize(12).fontColor('#6B7280')Text(this.formatMoney(Math.abs(this.chartData[this.selectedMonth].income-this.chartData[this.selectedMonth].expense))).fontSize(18).fontWeight(FontWeight.Bold).fontColor((this.chartData[this.selectedMonth].income-this.chartData[this.selectedMonth].expense)>=0?'#3B82F6':'#F59E0B')}.layoutWeight(1).alignItems(HorizontalAlign.Center)}.padding(16).backgroundColor(Color.White).borderRadius(12).shadow({radius:6,color:'#08000000'}).width('100%')}}.padding({left:32,right:32,top:32,bottom:32}).constraintSize({minWidth:700,maxWidth:1100}).margin({left:'auto',right:'auto'})}.width('100%').height('100%').backgroundColor('#F9FAFB')}}

Y轴网格线的实现

Y轴不用真正的绘图 API,用绝对定位的 Divider 模拟:

// 容器高度对应最大值// 4 条参考线:75%、50%、25%、0Column(){ForEach([1.0,0.75,0.5,0.25,0],(ratio)=>{Row(){Text(formatMoney(maxValue*ratio)).width(40)// Y轴标签Divider().layoutWeight(1).color(ratio===0?'#9CA3AF':'#F3F4F6')}})}.justifyContent(FlexAlign.SpaceBetween)// ← 均匀分布在容器高度

柱状图用Stack叠在网格线上,视觉上柱子就"立"在网格里了。

小结

柱状图的关键技巧:alignItems: VerticalAlign.Bottom让柱子底部对齐;柱子高度 = 容器高度 × 比例;Stack把网格线和柱子叠在一起。不需要 Canvas,纯 Flex + Column 就能做出够用的柱状图。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询