Building a Mood Calendar & Bézier Curve Chart in HarmonyOS
This tutorial demonstrates building a mental health app statistics page in HarmonyOS, covering a custom tab switcher, a Grid-based mood calendar with mock data, and a smooth mood trend curve drawn on Canvas using cubic Bézier interpolation.
Custom Tab Switcher
The system does not provide a ready-made tab switch component, so a custom one is built using a Row container as the background. Tabs are added by looping over a titles array with ForEach. Each tab is a Row containing a Text element whose font weight, background color, and click handler change based on the selected index. A thin divider Text (width 1) is conditionally rendered between tabs. The outer Row has padding, fixed height (30), rounded corners ( borderRadius('50%')), and a gray background.
Row() {
ForEach(this.titles, (title: ResourceStr, index) => {
Row() {
Text(title)
.fontSize(this.customFontSize)
.minFontSize('2fp')
.maxFontSize('16fp')
.maxLines(2)
.fontWeight(this.selectIndex == index ? FontWeight.Medium : FontWeight.Normal)
.fontColor('rgb(74,74,74)')
.textAlign(TextAlign.Center)
.height('100%')
.backgroundColor(this.selectIndex == index ? Color.White : 'rgb(216,216,216)')
.borderRadius('50%')
.onClick(() => {
this.selectIndex = index
this.itemClick(index)
})
.layoutWeight(1)
if (index < this.titles.length - 1) {
Text()
.backgroundColor((this.selectIndex == index || (this.selectIndex - 1 == index && this.selectIndex - 1 >= 0)) ? 'rgb(216,216,216)' : Color.Black)
.width(1)
.height('50%')
}
}
.justifyContent(FlexAlign.SpaceBetween)
.width(String((100 / this.titles.length).toString() + '%'))
})
}
.padding({ left: 2, right: 2, top: 2, bottom: 2 })
.width('100%')
.height(30)
.backgroundColor('rgb(216,216,216)')
.borderRadius('50%')
.clip(true)Mood Calendar with Grid
The calendar shows a month view with each day's mood icon. The difficulty lies in calculating the month's day count and the weekday of the 1st to know where to start. Mock data is used here: an array calendarList of 38 CalendarClass objects (empty strings for padding days, then day numbers 1–30 each with a mood resource like $r('app.media.m001')). A Grid with columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr') and maxCount(38) renders the list. Each GridItem shows an Image (24×24) and the day number in a centered Column when date is not empty.
@State calendarList: CalendarClass[] = [
{date: '', mood: $r('app.media.m001')}, {date: '', mood: $r('app.media.m001')},
{date: '1', mood: $r('app.media.m001')}, {date: '2', mood: $r('app.media.m002')}, {date: '3', mood: $r('app.media.m002')},
// ... up to day 30
]
Grid() {
ForEach(this.calendarList, (item: CalendarClass, index) => {
GridItem() {
if (item.date != '') {
Column({space: 3}) {
Image(item.mood).width(24).height(24)
Text(item.date).fontSize(14).fontColor('#4a4a4a')
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
.maxCount(38)Mood Trend Curve with Canvas & Cubic Bézier
The y‑axis uses emoji images instead of numeric labels, so third‑party chart libraries are unsuitable. A Canvas is used to draw a smooth curve through 7 data points (one per day). Drawing directly between the 7 points produces a polyline; to get a smooth curve, cubic Bézier interpolation is applied.
Step 1: Draw the points
Each point is drawn as a solid circle using arc with lineWidth = 4 (>= radius 2) and matching strokeStyle / fillStyle. The first and last points are skipped in this snippet.
for (let i = 0; i < hightPoints.length; i++) {
if (!(i == 0 || i == hightPoints.length - 1)) {
this.context.beginPath()
this.context.lineWidth = 4
this.context.strokeStyle = this.LINE_COLOR
this.context.fillStyle = this.LINE_COLOR
this.context.font = `12px sans-serif`
this.context.arc(hightPoints[i].x, hightPoints[i].y, 2, 0, 360)
this.context.stroke()
}
}Step 2: Compute Bézier control points
The function getAllPoints takes the original 7 points and returns an expanded array where each segment is represented by three control points (cp1, cp2, end). The algorithm uses a Catmull‑Rom‑like formula: for each interior point i, cp1 = current point, cp2 = current + (next - prev)/6, cp3 = next + (current - nextNext)/6, end = next point. The first point is added as the starting point.
getAllPoints(points: Point[]) {
const cp: Point[] = []
const bezierPoints: Point[] = []
bezierPoints.push({x: points[0].x, y: points[0].y})
for (let i = 1; (i + 2) < points.length; i++) {
const bezierItem = points[i]
cp[0] = {x: bezierItem.x, y: bezierItem.y}
cp[1] = {x: bezierItem.x + (points[i + 1].x - points[i - 1].x) / 6,
y: bezierItem.y + (points[i + 1].y - points[i - 1].y) / 6}
cp[2] = {x: points[i + 1].x + (points[i].x - points[i + 2].x) / 6,
y: points[i + 1].y + (points[i].y - points[i + 2].y) / 6}
cp[3] = {x: points[i + 1].x, y: points[i + 1].y}
bezierPoints.push(cp[1], cp[2], cp[3])
}
return bezierPoints
}Step 3: Stroke the Bézier curve
The expanded point array is traversed in steps of three, calling bezierCurveTo for each triplet. The path starts at the first point with moveTo.
let hightPoints = this.configCoordinateByTemp(this.high_temps, this.tempUnit, xUnit, Math.min(...this.low_temps))
let hight_total = this.getAllPoints(hightPoints)
this.context.moveTo(hight_total[0].x, hight_total[0].y)
for (let i = 1; i < hight_total.length - 2; i += 3) {
this.context.bezierCurveTo(
hight_total[i].x, hight_total[i].y,
hight_total[i + 1].x, hight_total[i + 1].y,
hight_total[i + 2].x, hight_total[i + 2].y
)
}
this.context.stroke()Mood Diary List
A simple vertical list built with Column({space: 6}). Each entry is a Row containing an emoji image, a bold title, tag rows (icon + label), the diary text, and a timestamp. Layout uses justifyContent(FlexAlign.Start) and alignItems for alignment.
Column({space: 6}) {
Row({space: 4}) {
Image($r('app.media.m001')).width(30).height(30)
Text('很不错').fontColor(Color.Black).fontSize(16).fontWeight(FontWeight.Bold)
}.width('100%').justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center)
Row({space: 4}) {
Row({space: 2}) {
Image($r('app.media.record11')).width(21).height(21)
Text('饮食').fontColor(Color.Gray).fontSize(13)
}.justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center)
Row({space: 2}) {
Image($r('app.media.record21')).width(21).height(21)
Text('咖啡').fontColor(Color.Gray).fontSize(13)
}.justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center)
}.width('100%').justifyContent(FlexAlign.Start)
Text('今天过得非常充实高效……').fontSize(14).fontColor('#4a4a4a').lineHeight(18)
Text('2026年4月30日 10:30').fontSize(14).fontColor(Color.Gray)
}.alignItems(HorizontalAlign.Start)Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
