Last Chance: RW Accelerator Bootcamps

Bootcamps start August 15th — Don't miss out!
Save 50% and go from novice coder to job-ready mobile developer in just 12 weeks.

Home Flutter & Dart Tutorials

Curved Line Charts in Flutter

Learn how to build Curved Line Charts in your Flutter app using the Canvas API.

Version

  • Dart 2.17, Flutter 3.0, Android Studio 4.2

So, you have some data, and you want to make a curved line chart? There are plenty of decent Flutter libraries out there to get the job done. But, if you want a uniquely beautiful chart that fits perfectly within the design aesthetic of your app, you’ll want to build it from scratch.

Flutter’s Canvas API is the perfect tool for drawing custom charts. This API is surprisingly intuitive, but if you want to start with a more general introduction, then check out Flutter Canvas API: Getting Started.

You should have at least an intermediate level of Flutter experience before diving into the Canvas API. If that sounds like you, then strap in and get ready to build some awesome charts!

In this tutorial you’ll build LOLTracker, an app that charts how often you laugh. This simple app will help you master the following Flutter principles:

  • Learning to draw curved lines with a CustomPaint() widget.
  • Mapping your curved line to follow data from a dataset.
  • Adding labels on the x and y axes of your chart.

Getting Started

Download the starter project by using the Download Materials button at the top or bottom of the tutorial. This tutorial uses Android Studio, but Visual Studio Code or IntelliJ IDEA works fine.

Begin by opening pubspec.yaml in your starter project and click the Pub get button to install all dependencies.

Pub get dependency

Then open an iPhone simulator or Android emulator and click run in Android Studio.

Run on iOS

You should see this on your virtual device:

App with chart placeholder

But what a hideous placeholder! Don’t worry, in a few simple steps you’ll transform that Placeholder() widget into a beautiful custom line chart. Here’s a sneak peek of the final project:

App final preview

These are the files in the lib folder:

  1. main.dart is the entrypoint of the application and contains pre-built UI for toggling between three weeks of laughing data.
  2. laughing_data.dart contains model classes and the data to be plotted on the chart.
  3. components/slide_selector.dart holds a switch to toggle between weeks.
  4. components/week_summary.dart contains the weekly summary UI.
  5. components/chart_labels.dart contains the UI to show chart labels.

Adding a CustomPaint() widget

You’ll be using a CustomPainter to draw your own line chart. In the main.dart file, replace the Placeholder() widget on line 144 with a CustomPaint() widget, like this:

CustomPaint(
  size: Size(MediaQuery.of(context).size.width, chartHeight),
  painter: PathPainter(
    path: drawPath(),
  ),
),

Save the code. You’re probably seeing red error warnings already. That’s because you still need to define the PathPainter, as well as the drawPath() function that goes into it.

At the bottom of the file after the DashboardBackground() widget, create the PathPainter class that extends from CustomPainter like this:

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(CustomPainter oldDelegate) => true;
}

This painter will draw a white line along whichever path you pass into it.

Inside your _DashboardState class, create a drawPath() function somewhere before the build method.

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;
}

The code above will define the path of the line you want to draw with your PathPainter:

Notice that moveTo() and lineTo() both take an x-coordinate and a y-coordinate as arguments. So, you’re using moveTo(0, height) to move the starting point of your line to the bottom-left corner of the painter.

Remember, the y-axis in a painter is inverted, which means 0 is at the top, and chartHeight is at the bottom of the y-axis. So, when you use height * 0.75 for the y-coordinate of your third line segment, that point is 25% of the way up from the bottom of the chart.

Build and run your code. Voilà! You’ve made a line chart. Thanks for reading this tutorial, see you next time.

Basic two-segment line

Oh, you’re still here? You’re not satisfied with the beautiful line you just learned to make? Okay, fine, then it’s time to dial up the cool-factor and learn how to connect this line to some data.

Adding Data

Open the laughing_data.dart file and look at all the data you’ll be charting.

Looking at the data, you must have been in high spirits the second week. That week there were several days with double-digit laughs, and one day you even laughed out loud 14 times! The third week wasn’t so great, though, as you never laughed more than four times on any given day. So sad…

You know that you want your chart to show one week’s worth of data at any given time.

Since you need to toggle between different charts for each of the three weeks, you’ll want to normalize the data. This means that for any week’s dataset, the values get scaled down to a number between 0.0 and 1.0. If your chart uses a normalized data set, it will draw the maximum data point at the top of the chart, whether it is 4 or 400.

You’ll add a normalizeData() function to do this, but first you need to create a list in your state that holds your chart data. First, you need to define a data type used in the charting.

Define the ChartDataPoint class at the very bottom of the file, after PathPainter:

class ChartDataPoint {
 double value;
 ChartDataPoint({required this.value});
}

Now for the list. Add the code below right after you define chartHeight in the _DashboardState class:

late List<ChartDataPoint> chartData;

This just holds a series of data points for your chart. Now to normalize the data for your chart. Add the normalizeData() function right after your initState method:

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;
}

The function above takes in a week’s worth of data from laughing_data.dart. It calculates the day with the maximum laughs, and returns a normalized list of ChartDataPoints with values ranging from 0.0 to 1.0.

At this point, it’s time to call it. In the initState method, use the normalizeData() function to initialize your chartData:

@override
void initState() {
 super.initState();
 setState(() {
   chartData = normalizeData(weeksData[activeWeek - 1]);
 });
}

You’ll also want to update chartData when the user toggles between weeks. Inside your changeWeek() function, right after you set activeWeek, add the following:

chartData = normalizeData(weeksData[week - 1]);

Now that chartData is a normalized List, loop through it in your drawPath() function:

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 = 1; i < chartData.length; i++) {
   final x = i * segmentWidth;
   final y = height - (chartData[i].value * height);
   path.lineTo(x, y);
 }
 return path;
}

Before digging into this new drawPath() code, build and run your code; you may need to do a Hot Restart. You'll see a line with seven data points; this should also update when you toggle between weeks.

Straight line charts using data

If you look at the data in laughing_data.dart, you'll see that these lines match the data for each week. But how exactly is this path created?

Take a closer look at the code you just added to drawPath(), and you can see how that raw data is being transformed into a nifty line that begins on the left side of the screen. The path.moveTo() method is setting the first point of the chart with an x-coordinate of 0, and a y-coordinate of height - chartData[0].value * height. Then you loop through the remaining chartData values to create six more lineTo() segments in your path.

The loop calculates the x-coordinate of each point with i * segmentWidth, where segmentWidth is one sixth of the screen width. It calculates the y-coordinate in the same way as the first point, with height - (chartData[i].value * height).

Now that you have a functional chart with data, the next step is to put some makeup on it. You'll add a fill to the path and learn how to turn these straight lines into curved ones.

Creating a Gradient Fill

To add a gradient fill to your chart, you need to add another section in the paint() method inside PathPainter. Right after you draw your line path with canvas.drawPath(path, paint), add the following code:

// 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);

Build and run the app, and you'll see that you've added a fill to your path. But it's not exactly what you want.

Line chart with fill

You want that gradient connected to the bottom of the chart, so you'll need to add two more segments to the drawPath() function to close the path. Right after the for loop inside drawPath(), add these two lines:

path.lineTo(width, height);
path.lineTo(0, height);

This closes the path, but if you look closely, you can see that the solid white line is now also following the closed path. This creates some unwanted visual artifacts.

Line chart with fill and closed path

This is not good, so you’ll need to set up two different paths – one for the solid line, and one for the gradient fill. To do this, add a closePath argument to your drawPath() function. Mind you, this will break your app, but don't worry, you'll fix it soon enoguth. Update drawPath to the following:

Path drawPath(bool closePath) {

Then wrap the two lines that close the path in an if-statement. The resulting drawPath will look like this:

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 = 1; 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;
}

Since you’re going to be passing two different paths into your painter now, add a second fillPath parameter to your PathPainter class, like this:

class PathPainter extends CustomPainter {
 Path path;
 Path fillPath;
 PathPainter({required this.path, required this.fillPath});

And when you draw your gradient, use fillPath instead of path. You can find this, right before the closing brace of the method:

  canvas.drawPath(fillPath, paint);
}

Lastly, update your CustomPaint() widget to pass two paths into the PathPainter, one that does close and one that doesn't:

CustomPaint(
  size: Size(MediaQuery.of(context).size.width, chartHeight),
  painter: PathPainter(
    path: drawPath(false),
    fillPath: drawPath(true),
  ),
),

Hot Reload the app! You're now passing two almost identical paths into your CustomPainter; the one for the fill that closes and the other for the line that doesn't close. Your chart looks pretty good, but those sharp jagged edges look dangerous. Time to learn how to smooth your line out and make it curvy.

Making Curved Lines

Creating curved lines in Flutter may seem daunting at first, but you’ll soon find it to be rather intuitive. The Path class includes many methods for drawing curved lines. In fact, you can use any of the following methods to create curves:

You will focus only on the cubicTo method, as it seems to work quite nicely when trying to draw a curve through several data points. The cubicTo method takes six arguments, or rather three different pairs of (x, y) coordinates. The (x3, y3) point is the ultimate destination of your line segment. The (x1, y1) and (x2, y2) coordinates create control points that act as handles that bend your line.

Don't worry if you don't 100% understand how cubicTo() is working yet. There is a diagram coming up that should help clear things up. Modify the drawPath() function as below:

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 * ssegmentWidth, height, 6 * segmentWidth, height);

  return path;
}

Build and run this code; you should see a nice big curved line.

Basic curved line

You can see the first thing this code does is define segmentWidth, which is the width of a line segment . There are only two cubicTo() segments, but remember that each cubicTo() has three different coordinate pairs. So, by setting segmentWidth to be equal to the width of the page divided by six, you can have three evenly spaced coordinates within each of your two cubicTo() segments.

This diagram will help you visualize the six segments used in these two cubicTo() methods:

CubicTo() diagram breakdown

The points overlaid on this diagram are color-coded to a moveTo() or cubicTo() method. You’ll see that the third redpoint from the left of the screen is the target destination of the first cubicTo() method. The first and second red points are control points that bend the line into a curve.

Spend some time studying this diagram until you feel comfortable with how the cubicTo() method works. Proceed to the next section, where you'll use this method with your laughing data when you're ready.

Combining Curved Lines with Data

You’ll need to update your drawPath() function again to loop through the laughing data and draw some curves using cubicTo(). Before doing that, now is a good time to add some padding constants.

Adding Padding

You'll need padding on the left and right sides of your charts to leave space for labels.

Near the top of your _DashboardState widget, right after you define chartHeight, add these constants:

static const leftPadding = 60.0;
static const rightPadding = 60.0;

Now that you have these padding constants, use them to define the segment width for your chart. Remember, there are three segments per cubicTo() method, so 18 total. This means your segment width is the screen width minus the padding, divided by 18. Update the segmentWidth in your drawPath() function:

final segmentWidth =
   (width - leftPadding - rightPadding) / ((chartData.length - 1) * 3);

Creating a cubicTo() Segment

Now replace the path segments you drew earlier with the following code. This will loop through your data points and create a cubicTo() segment for each data point:

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);
}

That is a lot of code, but this cubicTo() method follows the same principles that you learned in the diagram a bit earlier. You can see that inside your for loop, the y1 value of your cubicTo() is always the y value of the previous chart data point. This will ensure that there is a smooth transition between each cubicTo() method.

Again, look at the diagram from earlier if you need to visualize how these control points are working.

Build and run the app. Now you can toggle between each of the weeks to see three different curved lines:

Curved lines with data

The last thing left to do is add labels to your chart, and then you'll have a proper custom line chart!

Adding Labels

CustomPainters aren’t limited to painting paths; you can paint text within your CustomPainter as well. But painting text labels on a chart requires lots of math. It's often cleaner and simpler to create chart labels stacked behind a CustomPainter using standard layout widgets.

Your CustomPaint() widget is already sitting inside of a Stack() widget, so you can add your labels in this same Stack() so they render behind your chart.

Adding the X-Axis Labels

The first thing you’ll do is create some space under your chart for the x-axis labels. Look at your Container() that wraps your CustomPaint() widget. It should have a height of chartHeight. Add some padding underneath like this:

height: chartHeight + 80,

Next, wrap your CustomPaint() inside a Positioned() widget that sits 40 pixels from the top of the Stack():

Positioned(
  top: 40,
  child: CustomPaint(
    size:
        Size(MediaQuery.of(context).size.width, chartHeight),
    painter: PathPainter(
      path: drawPath(false),
      fillPath: drawPath(true),
    ),
  ),
)

Then, above this Positioned(), add another Positioned() to the bottom of the Stack() that contains a ChartDayLabels() widget. This widget will be your x-axis labels. This widget has already been set up for you, but you’ll learn how it works in detail next. Don’t forget to pass the chart padding constants into the widget, like this:

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,
      ),
    ),

Before diving into how this labels widget is working, Build and run your code. You should see each day label positioned right beneath its corresponding data point in the chart:

Added x-axis labels

Open components/chart_labels.dart, where you’ll see this ChartDayLabels() stateless widget. Notice it creates an array of Strings from ‘Sun’ to ‘Sat’, and maps each String to be a FractionalTranslation() widget inside of a Row(). The helper function labelOffset() helps calculate the exact offset of each Text() widget. This function is lining up the center of each Text() widget with the data point, instead of the left edge of each Text().

ChartDayLabels() also has a gradient background from Colors.white to Colors.white.withOpacity(0.85). This looks kind of silly though when the gradient resets between the labels and the chart. Fortunately there is an easy fix.

Back in the PathPainter inside of your main.dart file, change the second color in your gradient from Colors.white.withOpacity(1) to Colors.white.withOpacity(0.85).

Colors.white.withOpacity(0.85),

Now you should have a seamless connection between the gradient behind your labels and the gradient behind your chart.

Fixed gradient fill

Adding the Y-Axis Labels

Now you have your x-axis labels, it’s time to add the y-axis labels. At the very beginning of the Stack() wrapping your chart and x-axis labels, add your y-axis laugh labels like so above the Positioned widget:

ChartLaughLabels(
 chartHeight: chartHeight,
 topPadding: 40,
 leftPadding: leftPadding,
 rightPadding: rightPadding,
 weekData: weeksData[activeWeek - 1],
),

Build and Run this code. You'll see the y-axis labels and grid rendering nicely.

Added y-axis labels and grid lines

Look at ChartLaughLabels() inside components/chart_labels.dart. This labels widget requires five properties. The topPadding property is 40 pixels because, if you recall, your CustomPaint() widget is in a Positioned() that is 40 pixels from the top of the Stack(). It uses the weekData property to calculate the maxDay, which is the greatest number in the data set. The maxDay variable is then used for the highest label.

ChartLaughLabels() will create however many labels you specify with the labelCount variable. In this case, you're drawing four y-axis labels. It creates a List called labels that will store a double for all four of your labels. Your labels are equally divided between 0 and the maxDay value, so if your max value is 4 then your labels will be 0.0, 1.3, 2.7, and 4.0.

These labels are then mapped inside of a Column(), and it creates a thin grid line for each label. The Text() for each label is center-aligned with its grid line using the FractionalTranslation() trick from earlier.

And there you have it! You’ve created a beautiful curved line chart with a CustomPainter.

Final app

Where to Go From Here?

Compare your finished app to the completed project file. You can download it by clicking the Download Materials button at the top or bottom of the tutorial.

You learned a whole lot about creating a line chart with a CustomPainter, but believe it or not, there is a whole lot more you could do:

  • Using an animationController to transition your path from one chart to another when you toggle the data.
  • Adding a draggable marker that follows the path of the curve using PathMetrics.
  • You could show the exact value for each data point as you drag over top of it.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Contributors

Comments

Reviews

More like this