了解如何使用Canvas API在Flutter应用上绘制曲线图。
如果,你有一些数据,你想做一个曲线图?有很多不错的Flutter库可以完成这项工作。但是,如果您想要一个独特漂亮的图表,完全符合您的应用程序的设计,您将需要从头开始构建他。
Flutter的Canvas API是绘制自定义图标的完美工具。这个API非常直观。
在深入了解Canvas API之前,你应该至少具有中等水平的Flutter经验。如果这听起来像你,那么系好安全带并准备好构建一些很棒的图标!
在本教程中,您将将构建LOLTracker,这是一款可以记录您笑的频率的应用程序。这个简单的应用程序将帮助您掌握一下Flutter原则:
- 学习使用小部件绘制曲线
CustomPaint()
- 映射曲线以跟踪数据集中的数据
- 在图标的x轴和y轴添加标签
一、入门
为了更专注的绘制曲线,准备几个文件,运行之后会在设备上看到如下效果。
-
main.dart
是应用程序的入口点,包含用于三周的笑声数据之间切换的预构建UI。 -
layghing_data.dart
包含模型类和要在图标上绘制的数据。 -
components/slide_selector.dart
包含一个在一周之间切换的开关。 -
components/week_summary.dart
包含每周摘要UI。 -
components/chart_labels.dart
包含显示图表标签的UI。
最终项目的目标是变成下面的效果,咱们先睹为快:
如果想要快速运行Demo,请跳转最后章节直接复制代码。
二、添加一个CustomPaint()
小部件
您将使用CustomPainter
绘制您自己的折线图。在main.dart
文件中,将第113行的Placeholder()
的widget替换为CustomPaint()
widget.如下所示:
CustomPaint(
size: Size(MediaQuery.of(context).size.width, chartHeight),
painter: PathPainter(
path: drawPath(),
),
),
保存代码。您可能已经看到红色错误警告。那是因为您仍然需要定义PathPainter
,以及其中的drawPath()
函数。
在小部件之后的文件底部DashboardBackground
下面,创建CustomPainter
的拓展类PathPainter
。
class PathPainter extends CustomPainter {
Path path;
PathPainter({required this.path});
@override
void paint(Canvas canvas, Size size) {
// paint the line
final paint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
这个painter
会 沿着你进入的任何路径画一条白线。
在你的_DashboardState
类中,在build
函数某处创建一个方法drawPath()
Path drawPath() {
final width = MediaQuery.of(context).size.width;
final height = chartHeight;
final path = Path();
path.moveTo(0, height);
path.lineTo(width / 2, height * 0.5);
path.lineTo(width, height * 0.75);
return path;
}
上面的代码将定义您要回最后的线的路径PathPainter
:
请注意,moveTo()
和lineTo()
都将x坐标和y坐标作为参数。因此,您正在使用moveTo(0, height
将线条的起点移动到painter的左下角。
请记住,painter中y
轴是倒置的,这意味着0在顶部,而charHeight
在y
轴的底部。因此,当您使用height * 0.75
作为第三段的坐标时,该点是图标底部向上的25%。
构建并运行您的代码。瞧!你已经制作了折线图。
哦,你还在吗?刚刚学会的漂亮的线条你还不满意吗?好吧,好吧,那么是时候调高冷静并学习如何将这条线连接到一些数据了。
三、添加数据
打开laughing_data.dart
文件并查看您将绘制图表的所有数据。
看数据,第二周一定是精神抖擞。那个星期有好几天都是两位数的笑声,有一天你竟然笑出声来了14次!然而,第三周并不是那么好,因为你在任何一天都不会笑超过四次。好难过...
您知道您希望图表在任何给定时间显示一周数据。
由于您需要在三周的每一周的不同图表之间切换,因为您需要对数据进行标准化。这意味着对于任何一周的数据集,值都会缩小到0.0到1.0之间的数字。如果您的图表使用归一化数据集,它会在图表顶部绘制最大数据点,无论是4还是400.
您将添加一个normalizeData()
函数来执行此操作,但首先您需要在你所在的创建一个列表来保存您的图表数据。首先,您需要定义图表中使用的数据类型。
在文件的最底部,在PathPainter
之后定义ChartDataPoint
类:
class ChartDataPoint {
final double value;
ChartDataPoint({required this.value});
}
现在列表清单。chartHeight
在泪中确定后立即添加下面的代码_DashboardState
:
late List<ChartDataPoint> chartData;
这只是为您的图标保存了一系列数据点。现在规范化图标数据。normalizeData()
在initState方法之后添加函数:
List<ChartDataPoint> normalizeData(WeekData weekData) {
final maxDay = weekData.days.reduce((DayData dayA, DayData dayB) {
return dayA.laughs > dayB.laughs ? dayA : dayB;
});
final normalizedList = <ChartDataPoint>[];
for (var element in weekData.days) {
normalizedList.add(ChartDataPoint(value: maxDay.laughs == 0 ? 0 : element.laughs / maxDay.laughs));
}
return normalizedList;
}
上面代码从laughing_data.dart
中获得了一周的数据。它计算笑声最大的那天,并返回一个ChartDataPoints
数值范围为0.0到1.0的标准化列表。
此时,是时候调用它了。在initState
方法中,使用normalizeData()
函数初始化您的chartData
:
@override
void initState() {
super.initState();
setState(() {
chartData = normalizeData(weeksData[activeWeek - 1]);
});
}
chartData
当用户在周之间切换时,您还需要更新。在您的changeWeek()
函数内,在您设置之后activeWeek
,添加以下内容:
chartData = normalizeData(weeksData[activeWeek - 1]);
现在这chartData
是一个规范化的列表,在你的drawPath()
函数中循环遍历它:
Path drawPath() {
final width = MediaQuery.of(context).size.width;
final height = chartHeight;
final segmentWidth = width / (chartData.length - 1);
final path = Path();
path.moveTo(0, height - chartData[0].value * height);
for (var i = 0; i < chartData.length; ++i) {
final x = i * segmentWidth;
final y = height - (chartData[i].value * height);
path.lineTo(x, y);
}
return path;
}
在深入研究这个新drawPath()
代码之前,构建并运行您的代码;您可能需要进行热重启。您会看到一条包含七个数据点的线;当您在几周之间切换时,这也应该更新。
如果您查看laughing_data.dart
中的数据,你会发现这些行与每周的数据相匹配。但是这条路径究竟是如何创建的呢?
仔细查看您刚刚添加的代码drawPath()
,您可以看到原始数据试试如何转换为从屏幕左侧开始的行。该path.moveTo()
方法是将图表的第一个点x
坐标设置为0,将y
做哦表设置为height - chartDara[0].value * height
。然后循环遍历剩余的chartData
值以在路径中创建另外六个lineTo()
段。
该循环使用i * segmentWidth
计算每个点的x
坐标,其中segmentWidth
是屏幕宽度的六分之一。它以与第一个点相同的方式计算y
坐标height - (chartData[i].value * height)
。
现在您已经有了包含数据的功能图表,下一步是对其进行一些修饰。您向路径添加padding并学习如何将这些直线变成曲线。
四、创建渐变填充
要为您的图表添加渐变填充,您需要在PathPainter
的paint()
方法中添加另一个部分。在使用canvas.drawPath(path, paint)
绘制线条路径后,立即添加一下代码:
import 'dart:ui' as ui;
// paint the gradient fill
paint.style = PaintingStyle.fill;
paint.shader = ui.Gradient.linear(
Offset.zero,
Offset(0.0, size.height),
[
Colors.white.withOpacity(0.2),
Colors.white.withOpacity(1),
]
);
canvas.drawPath(path, paint);
构建并运行该应用程序,您会看到您已经在路径中添加了填充。但这并不是想要的。
您希望该渐变连接到图表底部,因此您需要向drawPath()
函数添加另外两个段以关闭历经。在drawPath()
中的for循环之后,添加以下两行:
path.lineTo(width, height);
path.lineTo(0, height);
这将关闭路径,但如果仔细观察,您还会发现白色实线也在沿着关闭的路径移动。这会产生一些不需要的视觉伪影。
和不好,因此您需要设置两条不同的路径参数——一条用于实线,一条用于渐变填充。为此,请向drawPath()
函数添加closePath
参数。请注意,这会破坏您的应用程序,但是别担心,您很快就会修复它。将drawPath
更新为以下内容:
Path drawPath(bool closePath) {
然后将closePat的两行包装在if语句中。生成的drawPath
将如下所示:
Path drawPath(bool closePath) {
final width = MediaQuery.of(context).size.width;
final height = chartHeight;
final segmentWidth = width / (chartData.length - 1);
final path = Path();
path.moveTo(0, height - chartData[0].value * height);
for (var i = 0; i < chartData.length; ++i) {
final x = i * segmentWidth;
final y = height - (chartData[i].value * height);
path.lineTo(x, y);
}
if (closePath) {
path.lineTo(width, height);
path.lineTo(0, height);
}
return path;
}
由于您现在要将两个不同的路径传递给您的painter
,因此将第二个fillPath
参数添加到您的PathPainter
类,如下所示:
class PathPainter extends CustomPainter {
Path path;
Path fillPath;
PathPainter({required this.path, required this.fillPath});
当您绘制渐变时,请使用fillPath
而不是路径。您可以在方法的右大括号之前找到它:
canvas.drawPath(fillPath, paint);
}
最后,更新您的CustomPaint()
小部件将两条路径传递到PathPainter
,一条关闭,一条不关闭:
CustomPaint(
size: Size(MediaQuery.of(context).size.width, chartHeight),
painter: PathPainter(
path: drawPath(false),
fillPath: drawPath(true)
),
)
热重载应用程序!您现在将两个几乎相同的路径传递到您的CustomPainter
;一个用于关闭填充,另一个用于不关闭的线。您的图表看起来不错,但那些尖锐的锯齿状边缘看起来很危险。是时候学习如何平滑你的线条并使其弯曲。
五、绘制曲线
在Flutter中创建曲线乍一看似乎令人生畏,但您很快就会发现它非常直观。Path 类包括许多绘制曲线的方法。实际上,您可以使用以下任何一种方法来创建曲线:
- addArc(添加圆弧)
- arcTo(弧至)
- arcToPoint(弧到点)
- conicTo(圆锥曲线)
- cubicTo(立方至)
- quadraticBezierTo(二次贝塞尔至)
- relativeArcToPoint(相对弧到点)
- relativeConicTo(相对圆锥)
- relativeCubicTo(相对立方)
- relativeQuadraticBezierTo(相对二次贝塞尔至)
您将只关注cubicTo
方法,因为它在尝试通过多个数据点回合制曲线时似乎工作得很好。该cubicTo
方法采用六个参数好,或者更确切地说是三对不同的(x, y)
坐标。(x3, y3)
点是线段的最终点。(x1, y1)
和(x2, y2)
坐标创建控制点,用作弯曲线条的手柄。
如果您还没有100%了解cubicTo()
工作原理,请不要担心。出现了一张图表,应该有助于理清思路。修改drawPath()
函数如下:
Path drawPath(bool closePath) {
final width = MediaQuery.of(context).size.width;
final height = chartHeight;
final path = Path();
final segmentWidth = width / 3 / 2;
path.moveTo(0, height);
path.cubicTo(segmentWidth, height, 2 * segmentWidth, 0, 3 * segmentWidth, 0);
path.cubicTo(4 * segmentWidth, 0, 5 * segmentWidth, height, 6 * segmentWidth, height);
return path;
}
}
构建并运行此代码;您应该看到一条漂亮的大曲线。
可以看到这段代码做的第一件事就是定义segmentWidth
,也就是线段的宽度。自有两个cubicTo()
段,但记住每个cubicTo()
都有三个不同的坐标对。因此,通过将segmentWidth
设置为登录页面宽度除以六,您可以在两个cubicTo()
段中的每一个中拥有三个均匀间隔的做哦表。
此图将帮助您可视化这两个cubicTo()
方法使用的六个部分:
覆盖在该图上的店用颜色编码为moveTo
或cubicTo()
方法。您会看到屏幕左侧的第三个红点是第一个cubicTo()
方法的目标目的地。第一个和第二个红点是将直线弯曲成曲线的控制点。
花一些时间研究此图,直到您对cubicTo()
方法的工作方式感到满意为止,继续下一届,准备好后,您将在笑声数据中使用此方法。
六、将曲线和数据结合
您需要再次更新drawPath()
函数以循环遍历笑声数据并使用cubicTo()
绘制一些曲线。在此之前,现在是添加一些天从常量的好是时机。
6.1、添加填充
你需要在图表的左侧和右侧进行填充,以便为标签留出空间。
在您的_DashboardState
小部件顶部奋进,在您定义chartHeight
之后,添加这些常量:
static const leftPadding = 60.0;
static const rightPadding = 60.0;
现在您有了这些填充常量,可以使用它们来定义图表的段宽度。请记住,每个cubicTo()
方法有三个段,因此总共有18个。这意味着您的段宽度是屏幕宽度减去填充,除以18.更新drawPath()
函数中的segmentWidth
:
final segmentWidth =
(width - leftPadding - rightPadding) / ((chartData.length - 1) * 3);
6.2、创建一个cubicTo()
段
现在用一下代码替换您之前绘制的路径。这将循环遍历您的数据点并为每个数据点创建一个cubicTo()
段:
path.moveTo(0, height - chartData[0].value * height);
path.lineTo(leftPadding, height - chartData[0].value * height);
// curved line
for (var i = 1; i < chartData.length; i++) {
path.cubicTo(
(3 * (i - 1) + 1) * segmentWidth + leftPadding,
height - chartData[i - 1].value * height,
(3 * (i - 1) + 2) * segmentWidth + leftPadding,
height - chartData[i].value * height,
(3 * (i - 1) + 3) * segmentWidth + leftPadding,
height - chartData[i].value * height);
}
path.lineTo(width, height - chartData[chartData.length - 1].value * height);
// for the gradient fill, we want to close the path
if (closePath) {
path.lineTo(width, height);
path.lineTo(0, height);
}
这是很多代码,但是这个cubicTo()
方法遵循您之前的图表所学到的相同原则。您可以看到在for循环内,cubicTo()
的y1值始终是前一个图表数据点的y值。这将确保每个cubicTo()
方法之间的平滑过渡。
如果您需要可视化这些控制点的工作方式,请再次查看之前的图表。
构建并运行应用程序。现在您可以在每周之间切换以查看三个不同的曲线。
剩下要做的最后一件事情是向图表添加标签,然后您将拥有一个合适的自定义折现。
七、添加标签
CustomPainters
不限于绘画路径;您也可以在CustomPainter
中绘制文本。但是在图表上绘制文本标签需要大量的数学知识。使用标准布局小部件创建堆叠在CustomPainter
后面的图表标签通常更清晰、变简单。
您的CustomPaint()
小部件已经位于Stack()
小部件内,因此您可以在同一个Stack()
中添加您的标签,以便它们呈现在您的图表后面。查看包装在CustonPaint()
7.1、添加X轴标签
您要做的第一件事是在图表下方的x轴标签创建一些空间。查看CustonPaint()
小部件的Container()
。它的高度应为chartHeight
。在下面添加一些填充,如下所示:
height: chartHeight + 80,
接下来,将CustomPaint()
包裹在一个Positioned()
小部件中,该小部件距离Stack()
顶部40像素:
Positioned(
top: 40,
child: CustomPaint(
size:
Size(MediaQuery.of(context).size.width, chartHeight),
painter: PathPainter(
path: drawPath(false), fillPath: drawPath(true)),
),
)
然后,在此Positioned()
之上,将另一个Positioned()
添加到包含ChartDayLabels()
小部件的Stack()
底部。这个小部件将是您的x轴标签。此小部件已为您设置好,但接下来您将详细了解它的工作原理。不要忘记将图表填充常量传递给小部件,如下所示:
Container(
height: chartHeight + 80,
color: const Color(0XFF158443),
child: Stack(
children: [ // old code
const Positioned(
bottom: 0,
left: 0,
right: 0,
child: ChartDayLabels(
leftPadding: leftPadding,
rightPadding: rightPadding,
)
),
在深入了解这个标签小部件的工作原理之前,构建您应该看到的每一天的标签都位于图表中相应数据点的正下方:
打开compinents/chart_labels.dart
,您将在其中看到这个ChartDayLabels()
无状态小部件。请注意,它创建了一个从"Sun"到"Sat"的字符串映射为Row()
内的FractioonalTranslation()
小部件。辅助函数labelOffset()
帮助计算每个Text()
小部件的精确偏移量。此函数将每个Text()
小部件的精确偏移量,此函数将每个Text()
小部件与数据点对齐,而不是每个Text()
的左边缘。
ChartDayLabels()
还具有从Colors.white
到Colors.white.withOpacity(0.85)
的渐变背景。当然当前标签和图表之间的渐变重置时,这看起来有点儿傻。幸运的是,有一个简单的修复方法。
回到main.dart
文件中的PathPainter
,将渐变中的第二种颜色从Colors.white.withOpacity(1)
更改为Colors.white.withOpacity(0.85)
。
Colors.white.withOpacity(0.85),
现在你应该在标签后面的渐变和图表后面的渐变之间建立无缝连接。
7.2、添加Y轴标签
现在您有了x轴标签,是时候添加y轴标签了,在Stack()
包装图表和x
轴标签最开始,像这样在Positioned
小部件上方添加y轴笑声坐标:
ChartLaughLabels(
chartHeight: chartHeight,
topPadding: 40,
leftPadding: leftPadding,
rightPadding: rightPadding,
weekData: weeksData[activeWeek - 1],
),
构建并运行次代码。您会很好地看到y轴标签和网络渲染。
查看components/chart_labels.dart
中的ChartLaughLabels()
。这个标签小部件需要五个属性。topPadding
属性为40像素,因为如果您还记得的话,您的CustomPaint()
小部件位于距Stack()
顶部40像素的Positioned()
中。它使用weekData
属性来计算maxDay
,这是数据集中的最大数字。然后将maxDay
变量用于最高标签。
ChartLaughLabels()
将创建您使用labelCount
变量指定任意多个标签。在这种情况下,您要绘制四个y轴标签。它创建了一个名为labels
的列表,它将存储所有四个标签的双精度值。您的标签在0和maxDay
值之间平均分配,因此如果您的最大值为4,那么您的标签将为0.0,1.3,2.7和4.0。
然后将这些标签映射到Column()
内部,并为每个标签创建一条细网格线。每个标签的Text()
使用前面的FractionalTranslation()
技巧与其网格线居中对齐。
你有它!您已经使用CustomPainter
创建了一个漂亮的曲线图。
十二、全部代码
main.dart
是应用程序的入口点,包含用于三周的笑声数据之间切换的预构建UI。
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/slide_selector.dart';
import 'components/week_summary.dart';
import 'components/chart_labels.dart';
import 'laughing_data.dart';
void main() {
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.white,
));
runApp(const LOLTrackerApp());
}
class LOLTrackerApp extends StatelessWidget {
const LOLTrackerApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'LOLTracker',
debugShowCheckedModeBanner: false,
home: Scaffold(
extendBodyBehindAppBar: true,
body: Dashboard(),
),
);
}
}
class Dashboard extends StatefulWidget {
const Dashboard({Key? key}) : super(key: key);
@override
_DashboardState createState() => _DashboardState();
}
class _DashboardState extends State<Dashboard>
with SingleTickerProviderStateMixin {
int activeWeek = 3;
static const leftPadding = 60.0;
static const rightPadding = 60.0;
PageController summaryController = PageController(
viewportFraction: 1,
initialPage: 3,
);
double chartHeight = 240;
late List<ChartDataPoint> chartData;
@override
void initState() {
super.initState();
setState(() {
chartData = normalizeData(weeksData[activeWeek - 1]);
});
}
List<ChartDataPoint> normalizeData(WeekData weekData) {
final maxDay = weekData.days.reduce((DayData dayA, DayData dayB) {
return dayA.laughs > dayB.laughs ? dayA : dayB;
});
final normalizedList = <ChartDataPoint>[];
weekData.days.forEach((element) {
normalizedList.add(ChartDataPoint(
value: maxDay.laughs == 0 ? 0 : element.laughs / maxDay.laughs));
});
return normalizedList;
}
void changeWeek(int week) {
setState(() {
activeWeek = week;
chartData = normalizeData(weeksData[week - 1]);
summaryController.animateToPage(week,
duration: const Duration(milliseconds: 300), curve: Curves.ease);
});
}
Path drawPath(bool closePath) {
final width = MediaQuery.of(context).size.width;
final height = chartHeight;
final path = Path();
final segmentWidth =
(width - leftPadding - rightPadding) / ((chartData.length - 1) * 3);
path.moveTo(0, height - chartData[0].value * height);
path.lineTo(leftPadding, height - chartData[0].value * height);
// curved line
for (var i = 1; i < chartData.length; i++) {
path.cubicTo(
(3 * (i - 1) + 1) * segmentWidth + leftPadding,
height - chartData[i - 1].value * height,
(3 * (i - 1) + 2) * segmentWidth + leftPadding,
height - chartData[i].value * height,
(3 * (i - 1) + 3) * segmentWidth + leftPadding,
height - chartData[i].value * height);
}
path.lineTo(width, height - chartData[chartData.length - 1].value * height);
// for the gradient fill, we want to close the path
if (closePath) {
path.lineTo(width, height);
path.lineTo(0, height);
}
return path;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
const DashboardBackground(),
ListView(
padding: EdgeInsets.zero,
children: [
Container(
height: 60,
margin: const EdgeInsets.only(top: 60),
alignment: Alignment.center,
child: const Text(
'LOL 😆 Tracker',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: SlideSelector(
defaultSelectedIndex: activeWeek - 1,
items: <SlideSelectorItem>[
SlideSelectorItem(
text: 'Week 1',
onTap: () {
changeWeek(1);
},
),
SlideSelectorItem(
text: 'Week 2',
onTap: () {
changeWeek(2);
},
),
SlideSelectorItem(
text: 'Week 3',
onTap: () {
changeWeek(3);
},
),
],
),
),
const SizedBox(height: 20),
Container(
height: chartHeight + 80,
color: const Color(0xFF158443),
child: Stack(
children: [
ChartLaughLabels(
chartHeight: chartHeight,
topPadding: 40,
leftPadding: leftPadding,
rightPadding: rightPadding,
weekData: weeksData[activeWeek - 1],
),
const Positioned(
bottom: 0,
left: 0,
right: 0,
child: ChartDayLabels(
leftPadding: leftPadding,
rightPadding: rightPadding,
),
),
Positioned(
top: 40,
child: CustomPaint(
size: Size(
MediaQuery.of(context).size.width, chartHeight),
painter: PathPainter(
path: drawPath(false),
fillPath: drawPath(true),
)),
)
],
),
),
Container(
color: Colors.white,
height: 400,
child: PageView.builder(
clipBehavior: Clip.none,
physics: const NeverScrollableScrollPhysics(),
controller: summaryController,
itemCount: 4,
itemBuilder: (_, i) {
return WeekSummary(week: i);
},
),
),
],
),
],
);
}
}
class DashboardBackground extends StatelessWidget {
const DashboardBackground({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: Container(
color: const Color(0xFF158443),
),
),
Expanded(
child: Container(
color: Colors.white,
),
),
],
);
}
}
class PathPainter extends CustomPainter {
Path path;
Path fillPath;
PathPainter({required this.path, required this.fillPath});
@override
void paint(Canvas canvas, Size size) {
// paint the line
final paint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
canvas.drawPath(path, paint);
// paint the gradient fill
paint.style = PaintingStyle.fill;
paint.shader = ui.Gradient.linear(
Offset.zero,
Offset(0.0, size.height),
[
Colors.white.withOpacity(0.2),
Colors.white.withOpacity(0.85),
],
);
canvas.drawPath(fillPath, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
class ChartDataPoint {
double value;
ChartDataPoint({required this.value});
}
layghing_data.dart
包含模型类和要在图标上绘制的数据。
List<WeekData> weeksData = [ WeekData( days: [ DayData( day: 0, laughs: 2, ), DayData( day: 1, laughs: 8, ), DayData( day: 2, laughs: 3, ), DayData( day: 3, laughs: 1, ), DayData( day: 4, laughs: 7, ), DayData( day: 5, laughs: 9, ), DayData( day: 6, laughs: 5, ), ],
),
WeekData(
days: [
DayData(
day: 0,
laughs: 9,
),
DayData(
day: 1,
laughs: 6,
),
DayData(
day: 2,
laughs: 11,
),
DayData(
day: 3,
laughs: 8,
),
DayData(
day: 4,
laughs: 14,
),
DayData(
day: 5,
laughs: 10,
),
DayData(
day: 6,
laughs: 4,
),
],
),
WeekData(
days: [
DayData(
day: 0,
laughs: 0,
),
DayData(
day: 1,
laughs: 2,
),
DayData(
day: 2,
laughs: 3,
),
DayData(
day: 3,
laughs: 0,
),
DayData(
day: 4,
laughs: 4,
),
DayData(
day: 5,
laughs: 3,
),
DayData(
day: 6,
laughs: 3,
),
],
),
];
WeekData zeroStateData = WeekData(
days: [
DayData(
day: 0,
laughs: 0,
),
DayData(
day: 1,
laughs: 0,
),
DayData(
day: 2,
laughs: 0,
),
DayData(
day: 3,
laughs: 0,
),
DayData(
day: 4,
laughs: 0,
),
DayData(
day: 5,
laughs: 0,
),
DayData(
day: 6,
laughs: 0,
),
],
);
class WeekData {
WeekData({required this.days});
List<DayData> days;
}
class DayData {
DayData({required this.day, required this.laughs});
int day;
int laughs;
}
components/slide_selector.dart
包含一个在一周之间切换的开关。
import 'package:flutter/material.dart';
class SlideSelector extends StatefulWidget {
List items;
int defaultSelectedIndex;
bool tappable;
SlideSelector(
{Key? key,
required this.items,
this.defaultSelectedIndex = 0,
this.tappable = true})
: super(key: key);
@override
_SlideSelectorState createState() => _SlideSelectorState();
}
class _SlideSelectorState extends State<SlideSelector> {
int activeItemIndex = 0;
@override
void initState() {
setState(() {
activeItemIndex = widget.defaultSelectedIndex;
});
super.initState();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final buttonWidth = (constraints.maxWidth) / 3;
return Container(
height: 52,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: <Widget>[
AnimatedPositioned(
top: 0,
left: activeItemIndex * buttonWidth,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
child: Container(
width: buttonWidth,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
spreadRadius: 0,
blurRadius: 8,
offset: const Offset(0.0, 3.0),
color: Colors.black.withOpacity(0.12),
),
BoxShadow(
spreadRadius: 0,
blurRadius: 1,
offset: const Offset(0.0, 3.0),
color: Colors.black.withOpacity(0.04),
),
],
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisSize: MainAxisSize.max,
children: widget.items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value as SlideSelectorItem;
return Flexible(
flex: 1,
child: GestureDetector(
onTap: () {
if (widget.tappable) {
item.onTap();
setState(() {
activeItemIndex = index;
});
}
},
child: Container(
height: 52,
// GestureDetector requires a color on the
// Container in order to recognize it
color: Colors.black.withOpacity(0.01),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: Alignment.center,
child: Text(
item.text,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: index == activeItemIndex
? Colors.black
: Colors.white,
),
),
),
),
),
);
}).toList(),
),
],
),
);
});
}
}
class SlideSelectorItem {
String text;
Function onTap;
SlideSelectorItem({required this.text, required this.onTap});
}
components/week_summary.dart
包含每周摘要UI。
import 'package:flutter/material.dart';
import '../laughing_data.dart';
class WeekSummary extends StatelessWidget {
const WeekSummary({Key? key, required this.week}) : super(key: key);
final int week;
String calculateLaughs([String filter = '']) {
final total = weeksData[week - 1].days.fold(0, (int acc, DayData cur) {
if ((filter == 'weekday' && (cur.day == 0 || cur.day == 6)) ||
(filter == 'weekend' && (cur.day > 0 && cur.day < 6))) {
return acc;
}
return acc + cur.laughs;
});
return total.toString();
}
String calculateMinMax([String filter = '']) {
final dayMax = weeksData[week - 1].days.reduce((DayData a, DayData b) {
if (a.laughs > b.laughs) {
return filter == 'worst' ? b : a;
} else
return filter == 'worst' ? a : b;
});
return [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ][dayMax.day];
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'Week $week',
style: const TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
ListItem(label: 'Total laughs', value: calculateLaughs()),
ListItem(label: 'Weekday laughs', value: calculateLaughs('weekday')),
ListItem(label: 'Weekend laughs', value: calculateLaughs('weekend')),
ListItem(label: 'Funniest day', value: calculateMinMax('funniest')),
ListItem(
label: 'Worst day',
value: calculateMinMax('worst'),
hideDivider: true,
),
],
),
);
}
}
class ListItem extends StatelessWidget {
const ListItem(
{Key? key,
required this.label,
required this.value,
this.hideDivider = false})
: super(key: key);
final String label;
final String value;
final bool hideDivider;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: const Color(0xFFD4D4D4).withOpacity(hideDivider ? 0 : 1),
width: 1,
),
),
),
child: Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Color(0xFF565656),
),
),
const Spacer(),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF565656),
),
)
],
),
);
}
}
components/chart_labels.dart
包含显示图表标签的UI。
import 'package:flutter/material.dart';
import '../laughing_data.dart';
class ChartDayLabels extends StatelessWidget {
const ChartDayLabels(
{Key? key, required this.leftPadding, required this.rightPadding})
: super(key: key);
final double leftPadding;
final double rightPadding;
Offset labelOffset(int length, double i) {
final segment = 1 / (length - 1);
final offsetValue = (i - ((length - 1) / 2)) * segment;
return Offset(offsetValue, 0);
}
@override
Widget build(BuildContext context) {
return Container(
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
stops: [0.0, 1.0],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.white, Colors.white.withOpacity(0.85)],
),
),
child: Padding(
padding: EdgeInsets.only(left: leftPadding, right: rightPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
.asMap()
.entries
.map(
(entry) => FractionalTranslation(
translation: labelOffset(7, entry.key.toDouble()),
child: SizedBox(
width: 36,
child: Text(
entry.value,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFFA9A9A9),
),
),
),
),
)
.toList(),
),
),
);
}
}
class ChartLaughLabels extends StatelessWidget {
const ChartLaughLabels({
Key? key,
required this.chartHeight,
required this.topPadding,
required this.leftPadding,
required this.rightPadding,
required this.weekData,
}) : super(key: key);
final double chartHeight;
final double topPadding;
final double leftPadding;
final double rightPadding;
final WeekData weekData;
@override
Widget build(BuildContext context) {
const labelCount = 4;
final maxDay = weekData.days.reduce((DayData a, DayData b) {
return a.laughs > b.laughs ? a : b;
});
final rowHeight = (chartHeight) / labelCount;
final labels = <double>[];
for (var i = 0; i < labelCount; i++) {
labels.add(maxDay.laughs.toDouble() -
(i * maxDay.laughs.toDouble() / (labelCount - 1)));
}
Offset labelOffset(int length, double i) {
final segment = 1 / (length - 1);
final offsetValue = (i - ((length - 1) / 2)) * segment;
return Offset(0, offsetValue);
}
return Container(
height: chartHeight + topPadding,
padding: EdgeInsets.only(top: topPadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: labels
.asMap()
.entries
.map(
(entry) => FractionalTranslation(
translation: labelOffset(labelCount, entry.key.toDouble()),
child: Container(
height: rowHeight,
alignment: Alignment.center,
child: Row(
children: [
SizedBox(
width: leftPadding,
child: Text(
entry.value.toStringAsFixed(1),
textAlign: TextAlign.center,
),
),
Expanded(
child: Container(
height: 2,
color: Colors.black.withOpacity(0.15),
),
),
SizedBox(width: rightPadding),
],
),
),
),
)
.toList(),
),
);
}
}
十三、github仓库地址
GitHub地址:curce_line_demo 欢迎star、fork、issue。