Flutter & OpenHarmony 运动App运动数据图表组件开发

前言
数据可视化是运动健康应用中帮助用户理解运动数据的关键功能。通过直观的图表展示,用户可以快速了解自己的运动趋势、发现规律、制定改进计划。本文将详细介绍如何在Flutter与OpenHarmony平台上实现专业的运动数据图表组件,包括折线图、柱状图、环形图、雷达图等多种图表类型的完整实现方案。
图表设计需要在信息密度和可读性之间取得平衡。过于复杂的图表会让用户感到困惑,过于简单的图表又无法传达足够的信息。我们需要根据不同的数据类型和使用场景,选择最合适的图表形式,并通过颜色、动画等视觉元素增强用户体验。
Flutter图表数据模型
class ChartDataPoint {
final DateTime date;
final double value;
final String? label;
ChartDataPoint({
required this.date,
required this.value,
this.label,
});
}
class ChartDataSet {
final String name;
final List<ChartDataPoint> points;
final Color color;
ChartDataSet({
required this.name,
required this.points,
required this.color,
});
double get maxValue => points.map((p) => p.value).reduce((a, b) => a > b ? a : b);
double get minValue => points.map((p) => p.value).reduce((a, b) => a < b ? a : b);
double get average => points.map((p) => p.value).reduce((a, b) => a + b) / points.length;
}
图表数据模型是所有图表组件的基础。ChartDataPoint表示单个数据点,包含日期、数值和可选的标签。ChartDataSet表示一个数据系列,包含系列名称、数据点列表和显示颜色。我们在数据集类中提供了maxValue、minValue和average三个计算属性,用于图表的坐标轴计算和统计显示。这种设计将数据结构与图表渲染分离,同一套数据可以用不同类型的图表展示,提高了代码的复用性和灵活性。
OpenHarmony图表数据查询服务
import relationalStore from '@ohos.data.relationalStore';
class ChartDataService {
private rdbStore: relationalStore.RdbStore | null = null;
async getWeeklySteps(): Promise<Array<object>> {
let data: Array<object> = [];
if (!this.rdbStore) return data;
let endDate = new Date();
let startDate = new Date();
startDate.setDate(startDate.getDate() - 6);
let predicates = new relationalStore.RdbPredicates('daily_stats');
predicates.between('date', startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]);
let resultSet = await this.rdbStore.query(predicates, ['date', 'steps']);
while (resultSet.goToNextRow()) {
data.push({
date: resultSet.getString(resultSet.getColumnIndex('date')),
value: resultSet.getDouble(resultSet.getColumnIndex('steps')),
});
}
resultSet.close();
return data;
}
async getMonthlyCalories(): Promise<Array<object>> {
// 查询月度卡路里数据
return [];
}
}
图表数据查询服务从数据库获取图表所需的数据。getWeeklySteps方法查询最近7天的步数数据,使用between条件筛选日期范围。查询结果包含日期和步数两个字段,封装为对象数组返回。这种服务层设计将数据获取逻辑与UI展示分离,便于数据源的切换和测试。不同的图表可能需要不同时间范围和聚合方式的数据,服务层可以提供多个查询方法满足各种需求。
Flutter折线图组件
class LineChartWidget extends StatelessWidget {
final ChartDataSet dataSet;
final double height;
const LineChartWidget({
Key? key,
required this.dataSet,
this.height = 200,
}) : super(key: key);
Widget build(BuildContext context) {
return Container(
height: height,
padding: EdgeInsets.all(16),
child: CustomPaint(
size: Size(double.infinity, height - 32),
painter: LineChartPainter(dataSet: dataSet),
),
);
}
}
class LineChartPainter extends CustomPainter {
final ChartDataSet dataSet;
LineChartPainter({required this.dataSet});
void paint(Canvas canvas, Size size) {
if (dataSet.points.isEmpty) return;
Paint linePaint = Paint()
..color = dataSet.color
..strokeWidth = 2
..style = PaintingStyle.stroke;
Paint dotPaint = Paint()
..color = dataSet.color
..style = PaintingStyle.fill;
Path path = Path();
double xStep = size.width / (dataSet.points.length - 1);
double yRange = dataSet.maxValue - dataSet.minValue;
for (int i = 0; i < dataSet.points.length; i++) {
double x = i * xStep;
double y = size.height - ((dataSet.points[i].value - dataSet.minValue) / yRange) * size.height;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
canvas.drawCircle(Offset(x, y), 4, dotPaint);
}
canvas.drawPath(path, linePaint);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
折线图是展示数据趋势的经典图表类型。我们使用CustomPaint实现自定义绑制,LineChartPainter负责具体的绑制逻辑。首先计算x轴步长和y轴范围,然后遍历数据点,将数值映射到画布坐标。Path对象连接所有数据点形成折线,同时在每个数据点位置绘制圆点标记。y轴的映射需要注意坐标系转换,画布的y轴向下增长,而数据的y轴向上增长,所以使用size.height减去计算值。这种折线图适合展示步数、心率等随时间变化的数据。
Flutter柱状图组件
class BarChartWidget extends StatelessWidget {
final List<ChartDataPoint> data;
final Color barColor;
final double height;
const BarChartWidget({
Key? key,
required this.data,
this.barColor = Colors.blue,
this.height = 200,
}) : super(key: key);
Widget build(BuildContext context) {
double maxValue = data.map((d) => d.value).reduce((a, b) => a > b ? a : b);
return Container(
height: height,
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: data.map((point) {
double barHeight = maxValue > 0 ? (point.value / maxValue) * (height - 40) : 0;
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('${point.value.toInt()}', style: TextStyle(fontSize: 10)),
SizedBox(height: 4),
Container(
height: barHeight,
decoration: BoxDecoration(
color: barColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
),
SizedBox(height: 4),
Text(point.label ?? '', style: TextStyle(fontSize: 10)),
],
),
),
);
}).toList(),
),
);
}
}
柱状图适合展示离散的分类数据比较。我们使用Row和Expanded组件实现柱子的均匀分布,每根柱子的高度按数据值与最大值的比例计算。柱子顶部显示具体数值,底部显示标签(如星期几)。圆角设计使柱子看起来更加柔和。这种实现方式利用Flutter的布局系统,无需手动计算位置,代码简洁且易于维护。柱状图常用于展示每日步数、每周运动次数等数据,让用户直观比较不同时间段的运动量。
OpenHarmony数据聚合服务
class DataAggregationService {
aggregateByDay(records: Array<object>): Map<string, number> {
let dailyData = new Map<string, number>();
for (let record of records) {
let date = record['date'] as string;
let value = record['value'] as number;
if (dailyData.has(date)) {
dailyData.set(date, dailyData.get(date)! + value);
} else {
dailyData.set(date, value);
}
}
return dailyData;
}
aggregateByWeek(records: Array<object>): Array<object> {
let weeklyData: Array<object> = [];
let weekMap = new Map<number, number>();
for (let record of records) {
let date = new Date(record['date'] as string);
let weekNumber = this.getWeekNumber(date);
let value = record['value'] as number;
if (weekMap.has(weekNumber)) {
weekMap.set(weekNumber, weekMap.get(weekNumber)! + value);
} else {
weekMap.set(weekNumber, value);
}
}
weekMap.forEach((value, week) => {
weeklyData.push({ week: week, value: value });
});
return weeklyData;
}
private getWeekNumber(date: Date): number {
let firstDay = new Date(date.getFullYear(), 0, 1);
let days = Math.floor((date.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000));
return Math.ceil((days + firstDay.getDay() + 1) / 7);
}
}
数据聚合服务将原始数据按不同维度汇总。aggregateByDay方法将同一天的多条记录合并,适用于一天内有多次运动的情况。aggregateByWeek方法按周汇总数据,用于生成周报图表。getWeekNumber方法计算日期所在的周数,基于年初第一天进行计算。这种聚合服务在数据层处理,减少了传输到前端的数据量,也简化了图表组件的逻辑。不同的图表可能需要不同粒度的数据,聚合服务提供了灵活的数据处理能力。
Flutter环形进度图
class CircularProgressChart extends StatelessWidget {
final double progress;
final double size;
final Color progressColor;
final Color backgroundColor;
final String centerText;
const CircularProgressChart({
Key? key,
required this.progress,
this.size = 150,
this.progressColor = Colors.blue,
this.backgroundColor = Colors.grey,
this.centerText = '',
}) : super(key: key);
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: Size(size, size),
painter: CircularProgressPainter(
progress: progress,
progressColor: progressColor,
backgroundColor: backgroundColor,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${(progress * 100).toInt()}%', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
if (centerText.isNotEmpty)
Text(centerText, style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
);
}
}
class CircularProgressPainter extends CustomPainter {
final double progress;
final Color progressColor;
final Color backgroundColor;
CircularProgressPainter({
required this.progress,
required this.progressColor,
required this.backgroundColor,
});
void paint(Canvas canvas, Size size) {
double strokeWidth = 12;
Offset center = Offset(size.width / 2, size.height / 2);
double radius = (size.width - strokeWidth) / 2;
Paint bgPaint = Paint()
..color = backgroundColor.withOpacity(0.2)
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
Paint progressPaint = Paint()
..color = progressColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
double sweepAngle = 2 * 3.14159 * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-3.14159 / 2,
sweepAngle,
false,
progressPaint,
);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
环形进度图是展示目标完成度的理想选择。我们使用CustomPaint绑制两层圆弧:底层是灰色的完整圆环作为背景,上层是彩色的进度弧。进度弧的角度根据progress值计算,从顶部(-π/2)开始顺时针绘制。strokeCap设为round使弧线端点圆润。中央使用Stack叠加显示百分比数字和说明文字。这种图表非常适合展示每日步数目标、卡路里目标等单一指标的完成情况,视觉效果直观且占用空间小。
Flutter多数据对比图
class ComparisonChart extends StatelessWidget {
final List<ChartDataSet> dataSets;
final List<String> labels;
const ComparisonChart({
Key? key,
required this.dataSets,
required this.labels,
}) : super(key: key);
Widget build(BuildContext context) {
return Container(
height: 250,
padding: EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: dataSets.map((ds) => Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Container(width: 12, height: 12, color: ds.color),
SizedBox(width: 4),
Text(ds.name, style: TextStyle(fontSize: 12)),
],
),
)).toList(),
),
SizedBox(height: 16),
Expanded(
child: CustomPaint(
size: Size(double.infinity, double.infinity),
painter: ComparisonChartPainter(dataSets: dataSets),
),
),
],
),
);
}
}
多数据对比图支持在同一图表中展示多个数据系列。顶部的图例区域显示每个数据系列的名称和对应颜色,帮助用户区分不同的数据线。图表区域使用CustomPaint绑制多条折线,每条线使用数据集定义的颜色。这种图表适合比较不同时期的运动数据(如本周vs上周),或者比较不同类型的数据(如步数vs距离)。通过对比,用户可以发现自己的进步或需要改进的地方。
OpenHarmony图表数据导出
import fileIo from '@ohos.file.fs';
class ChartDataExportService {
async exportToCSV(data: Array<object>, fileName: string): Promise<string> {
let csvContent = 'Date,Value\n';
for (let item of data) {
csvContent += `${item['date']},${item['value']}\n`;
}
let filePath = globalThis.context.filesDir + '/' + fileName + '.csv';
let file = await fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(file.fd, csvContent);
await fileIo.close(file);
return filePath;
}
async exportToJSON(data: Array<object>, fileName: string): Promise<string> {
let jsonContent = JSON.stringify(data, null, 2);
let filePath = globalThis.context.filesDir + '/' + fileName + '.json';
let file = await fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(file.fd, jsonContent);
await fileIo.close(file);
return filePath;
}
}
图表数据导出服务允许用户将运动数据导出为文件。exportToCSV方法生成CSV格式的文件,这是一种通用的表格数据格式,可以用Excel等软件打开。exportToJSON方法生成JSON格式的文件,便于程序处理和数据迁移。两个方法都使用fileIo模块进行文件操作,文件保存在应用的私有目录中。导出功能让用户可以备份自己的运动数据,或者导入到其他应用进行更深入的分析。
Flutter图表动画效果
class AnimatedBarChart extends StatefulWidget {
final List<double> values;
final List<String> labels;
const AnimatedBarChart({Key? key, required this.values, required this.labels}) : super(key: key);
State<AnimatedBarChart> createState() => _AnimatedBarChartState();
}
class _AnimatedBarChartState extends State<AnimatedBarChart> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
void initState() {
super.initState();
_controller = AnimationController(duration: Duration(milliseconds: 800), vsync: this);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_controller.forward();
}
Widget build(BuildContext context) {
double maxValue = widget.values.reduce((a, b) => a > b ? a : b);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
height: 200,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(widget.values.length, (index) {
double targetHeight = (widget.values[index] / maxValue) * 160;
double currentHeight = targetHeight * _animation.value;
return Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
height: currentHeight,
margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
),
SizedBox(height: 8),
Text(widget.labels[index], style: TextStyle(fontSize: 10)),
],
),
);
}),
),
);
},
);
}
void dispose() {
_controller.dispose();
super.dispose();
}
}
图表动画效果提升用户体验,让数据展示更加生动。我们使用AnimationController控制动画时长和进度,CurvedAnimation添加缓动效果使动画更自然。AnimatedBuilder在每一帧重建UI,柱子高度根据动画进度从0增长到目标高度。easeOutCubic曲线使动画开始快结束慢,符合物理直觉。动画在组件初始化时自动播放,用户打开图表页面时会看到柱子从底部生长的效果。这种动画不仅美观,还能引导用户的注意力,帮助他们理解数据。
总结
本文全面介绍了Flutter与OpenHarmony平台上运动数据图表组件的实现方案。从数据模型到查询服务,从折线图到柱状图,从环形图到对比图,涵盖了图表功能的各个方面。通过合理的图表选择和精心的视觉设计,我们可以将枯燥的运动数据转化为直观的可视化展示,帮助用户更好地理解自己的运动表现,发现规律,制定改进计划。
更多推荐



所有评论(0)