Line Chart in Flutter – WeightTracker 6

Line Chart in Flutter – WeightTracker 6

In this post I will go through the process of creating line chart for my WeightTracker app. Since there is no official support for drawing charts yet, we will do it on our own 🙂 . Expected result is a Widget displaying history of weight entries from last month. X axis will represent time (days) while Y axis will represent values (weights). Let’s get to it!

End result

1. Introduction

At first I created StatelessWidget which contains CustomPaint. This Widget can be used anywhere, in my case I will display it inside a Card. I won’t discuss logic connected to preparing data since it is strictly connected to my business logic (see full code if interested).

class ProgressChart extends StatelessWidget {
  static const int NUMBER_OF_DAYS = 31;
  final List<WeightEntry> entries;

  ProgressChart(this.entries);

  @override
  Widget build(BuildContext context) {
    return new CustomPaint(
      painter: new ChartPainter(_prepareEntryList(entries)),
    );
  }

  List<WeightEntry> _prepareEntryList(List<WeightEntry> initialEntries) {
    DateTime beginningDate = _getStartDateOfChart();
    List<WeightEntry> entries = initialEntries
        .where((entry) => entry.dateTime.isAfter(beginningDate))
        .toList();
    [...]
    return entries;
  }
}

Next step is to create ChartPainter – a class that inherits from CustomPainter and is our most important class since there all painting will take place:

class ChartPainter extends CustomPainter {
  final List<WeightEntry> entries;

  ChartPainter(this.entries);

  double leftOffsetStart;
  double topOffsetEnd;
  double drawingWidth;
  double drawingHeight;

  static const int NUMBER_OF_HORIZONTAL_LINES = 5;

  @override
  void paint(Canvas canvas, Size size) {
    leftOffsetStart = size.width * 0.05;
    topOffsetEnd = size.height * 0.9;
    drawingWidth = size.width * 0.95;
    drawingHeight = topOffsetEnd;

    Tuple2<int, int> borderLineValues = _getMinAndMaxValues(entries);
    _drawHorizontalLinesAndLabels(canvas, size, borderLineValues.item1, borderLineValues.item2);
    _drawBottomLabels(canvas, size);
    _drawLines(canvas, borderLineValues.item1, borderLineValues.item2);

  }

  @override
  bool shouldRepaint(ChartPainter old) => true;
}

I created 4 fields (leftOffsetStart, topOffsetEnd, drawingWidth, drawingHeight) to calculate area of drawings. We don’t want to use full size we get, because we need space for labels and small margins.
ShouldRepaint method ideally would check if we really should repaint our canvas but this is not important in the scope of this post.

2. Calculating Y range

Since we are not using any charting library, we have to calculate range of values we would like to present. I assumed, that my chart will have 5 horizontal lines each representing integer value.

///Produces minimal and maximal value of horizontal line that will be displayed
Tuple2<int, int> _getMinAndMaxValues(List<WeightEntry> entries) {
  double maxWeight = entries.map((entry) => entry.weight).reduce(math.max);
  double minWeight = entries.map((entry) => entry.weight).reduce(math.min);

  int maxLineValue = maxWeight.ceil();
  int difference = maxLineValue - minWeight.floor();
  int toSubtract = (NUMBER_OF_HORIZONTAL_LINES - 1) - (difference % (NUMBER_OF_HORIZONTAL_LINES - 1));
  if (toSubtract == NUMBER_OF_HORIZONTAL_LINES - 1) {
    toSubtract = 0;
  }
  int minLineValue = minWeight.floor() - toSubtract;

  return new Tuple2(minLineValue, maxLineValue);
}

Since there is no Pair in Dart and I wanted to return min and max value with one method, I had to use Tuple2 from Tuple package. To do that I needed to include Tuple package in pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  tuple: "^1.0.1"

When it comes to calculating border values, at first I get ceil from max weight as max value. After that I calculate difference between min and max value. Then I need to change minimal value so that difference between min and max values is multiple of 4 (number of horizontal values – 1). Let’s crack that by example:
Let’s say that minimal weight in our data equals 7.4 and maximal 13.8.
maxLineValue = (13.8).ceil() = 14
difference = 14 – (7.4).floor() = 14 – 7 = 7
toSubtract = 4 – (difference % 4) = 4 – (7%4) = 4 – 3 = 1
minLineValue = (7.4).floor() – toSubtract = 7 – 1 = 6
Now having 5 lines, their labels would be accordingly: 6, 8, 10, 12, 14.

3. Drawing horizontal lines and labels

Let’s get to drawing horizontal lines and labels. We’ll create grey Paint for lines and inside a for loop we will draw them on canvas:

/// Draws horizontal lines and labels informing about weight values attached to those lines
void _drawHorizontalLinesAndLabels(Canvas canvas, Size size, int minLineValue, int maxLineValue) {
  final paint = new Paint()
    ..color = Colors.grey[300];
  int lineStep = _calculateHorizontalLineStep(maxLineValue, minLineValue);
  double offsetStep = _calculateHorizontalOffsetStep;
  for (int line = 0; line < NUMBER_OF_HORIZONTAL_LINES; line++) {
    double yOffset = line * offsetStep;
    _drawHorizontalLabel(maxLineValue, line, lineStep, canvas, yOffset);
    _drawHorizontalLine(canvas, yOffset, size, paint);
  }
}

At first we need to calculate the weight step between every horizontal line. According to previous example it would be equal to 2:

/// Calculates weight difference between horizontal lines.
///
/// e.g. every line should increment weight by 5
int _calculateHorizontalLineStep(int maxLineValue, int minLineValue) {
 return (maxLineValue - minLineValue) ~/ (NUMBER_OF_HORIZONTAL_LINES - 1);
}

Then we calculate pixel difference between lines based on drawing height and number of lines. We will use that value so that every line will have Y-offset equal to numberOfLine*offsetStep :

/// Calculates offset difference between horizontal lines.
///
/// e.g. between every line should be 100px space.
double get _calculateHorizontalOffsetStep {
 return drawingHeight / (NUMBER_OF_HORIZONTAL_LINES - 1);
}

When we get those, we can paint a line. To do that we need to call Canvas’ drawLine method with Offsets of starting and ending points. The ‘+5’ is there because I wanted to move chart a bit down:

void _drawHorizontalLine(ui.Canvas canvas, double yOffset, ui.Size size, ui.Paint paint) {
  canvas.drawLine(
    new Offset(leftOffsetStart, 5 + yOffset),
    new Offset(size.width, 5 + yOffset),
    paint,
  );
}

Last thing we need to do is add labels to horizontal lines. Most of code are just aesthetic patches:

void _drawHorizontalLabel(int maxLineValue, int line, int lineStep, ui.Canvas canvas, double yOffset) {
 ui.Paragraph paragraph = _buildParagraphForLeftLabel(maxLineValue, line, lineStep);
 canvas.drawParagraph(paragraph, new Offset(0.0, yOffset));
}

///Builds text paragraph for label placed on the left side of a chart (weights)
ui.Paragraph _buildParagraphForLeftLabel(int maxLineValue, int line, int lineStep) {
  ui.ParagraphBuilder builder = new ui.ParagraphBuilder(
    new ui.ParagraphStyle(
      fontSize: 10.0,
      textAlign: TextAlign.right,
    ),
  )
    ..pushStyle(new ui.TextStyle(color: Colors.black))
    ..addText((maxLineValue - line * lineStep).toString());
  final ui.Paragraph paragraph = builder.build()
    ..layout(new ui.ParagraphConstraints(width: leftOffsetStart - 4));
  return paragraph;
}

Right now this is what we got:

 

4. Drawing bottom labels

Process of creating bottom labels is pretty similar to left-side labels.
Instead of iterating through number of lines, we do it by weeks (7 days), starting from today and moving 7 day backwards with each iteration. To calculate left offset we simply divide drawing width by number of days in total and multiplying it by number of days in current iteration:

void _drawBottomLabels(Canvas canvas, Size size) {
  for (int daysFromStart = ProgressChart.NUMBER_OF_DAYS;
      daysFromStart >= 0;
      daysFromStart -= 7) {
    double offsetXbyDay = drawingWidth / (ProgressChart.NUMBER_OF_DAYS);
    double offsetX = leftOffsetStart + offsetXbyDay * daysFromStart;
    ui.Paragraph paragraph = _buildParagraphForBottomLabel(daysFromStart);
    canvas.drawParagraph(
      paragraph,
      new Offset(offsetX - 50.0, 10.0 + drawingHeight),
    );
  }
}

///Builds paragraph for label placed on the bottom (dates)
ui.Paragraph _buildParagraphForBottomLabel(int daysFromStart) {
  ui.ParagraphBuilder builder = new ui.ParagraphBuilder(
      new ui.ParagraphStyle(fontSize: 10.0, textAlign: TextAlign.right))
    ..pushStyle(new ui.TextStyle(color: Colors.black))
    ..addText(new DateFormat('d MMM').format(new DateTime.now().subtract(
        new Duration(days: ProgressChart.NUMBER_OF_DAYS - daysFromStart))));
  final ui.Paragraph paragraph = builder.build()
    ..layout(new ui.ParagraphConstraints(width: 50.0));
  return paragraph;
}

5. Drawing actual data lines

At first we are creating blue Paint for our lines. Then for every entry we are going to draw line connecting that entry to the next one and we will draw circle (a dot) representing next entry. At last we will add bigger dot to the first entry, this dot will represent current state (since in my example first value is the most recent one and last value is the oldest):

///draws actual chart
void _drawLines(ui.Canvas canvas, int minLineValue, int maxLineValue) {
  final paint = new Paint()
    ..color = Colors.blue[400]
    ..strokeWidth = 3.0;
  DateTime beginningOfChart = _getStartDateOfChart();
  for (int i = 0; i < entries.length - 1; i++) {
    Offset startEntryOffset = _getEntryOffset(entries[i], beginningOfChart, minLineValue, maxLineValue);
    Offset endEntryOffset = _getEntryOffset(entries[i + 1], beginningOfChart, minLineValue, maxLineValue);
    canvas.drawLine(startEntryOffset, endEntryOffset, paint);
    canvas.drawCircle(endEntryOffset, 3.0, paint);
  }
  canvas.drawCircle(
      _getEntryOffset(entries.first, beginningOfChart, minLineValue, maxLineValue),
      5.0,
      paint);
}

To draw those lines, we need offsets representing values in our data. First thing to do is getting relative x and y positions which tell us in what ‘part’ of drawing area point will be placed (from 0 to 1 where 0 is beginning and 1 is end). To calculate actual offset we need to get starting position (our margin) and multiply relative value with actual drawing size. As a result we get Offset representing one entry:

/// Calculates offset at which given entry should be painted
Offset _getEntryOffset(WeightEntry entry, DateTime beginningOfChart, int minLineValue, int maxLineValue) {
  int daysFromBeginning = entry.dateTime.difference(beginningOfChart).inDays;
  double relativeXposition = daysFromBeginning / ProgressChart.NUMBER_OF_DAYS;
  double xOffset = leftOffsetStart + relativeXposition * drawingWidth;
  double relativeYposition = (entry.weight - minLineValue) / (maxLineValue - minLineValue);
  double yOffset = 5 + drawingHeight - relativeYposition * drawingHeight;
  return new Offset(xOffset, yOffset);
}

6. Wrapping up

Having everything together, we end up with chart that looks like this:

And that’s it! 🙂

Even if the full process may look complicated, once you get into it, it gets pretty easy, so until we have official charting library we can still do charts on our own. 🙂

I didn’t mention every aspect of my implementation (mostly connected to data transformation). If you are interested in more details, see my GitHub repository for more. 🙂

 

 

One thought on “Line Chart in Flutter – WeightTracker 6

Leave a Reply

Your email address will not be published.