Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
05-17 00:00
관리 메뉴

nomad-programmer

[Programming/Flutter] CustomPainter를 이용한 차트(그래프) 본문

Programming/Flutter

[Programming/Flutter] CustomPainter를 이용한 차트(그래프)

scii 2020. 10. 22. 19:23

pub.dev에 그래프 라이브러리가 많다. 하지만 입맛에 맞는 그래프 라이브러리가 없는 경우에는 직접 그래프를 만들어줘야 한다. 이럴때 사용하는 것이 CustomPaint & CustomPainter 이다.

커스텀 페인터는 직접 UI를 그릴때 사용한다. 기존의 UI로 만들기 어려운 화면을 만들고 싶을 때 유용하다. 직접 UI를 그리기위해서는 CustomPaint와 CustomPainter 클래스가 있어야 한다.

  • CustomPaint : Center 위젯이나 Container 위젯같은 것이다. 즉, CustomPainter클래스를 담는 그릇이다. 이 클래스는 painter 속성을 가지고 있으므로 이것을 활용해 화면을 그릴 수 있다.
  • CustomPainter : canvas, paint, size 등을 통해 실제 화면을 그릴 때 사용되는 클래스이다. 선, 원 등 다양한 그리기 함수가 존재한다.

직선 그리기

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: PainterPage(),
    );
  }
}

class PainterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('custom chart page'),
      ),
      body: CustomPaint(
        // 위젯의 크기를 정한다.
        size: Size(200, 200),
        // painter 속성에 그리기를 담당할 클래스를 넣는다.
        painter: MyPainter(),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  // 화면을 새로 그릴때 호출된다.
  @override
  void paint(Canvas canvas, Size size) {
    // Paint 클래스는 어떤식으로 화면을 그릴지 정할 때 사용한다.
    Paint paint = Paint()
      // 색은 주황색
      ..color = Colors.deepOrangeAccent
      // 선의 끝은 각지게 표현
      ..strokeCap = StrokeCap.square
      // 선의 굵기는 8.0
      ..strokeWidth = 8.0;

    // 선을 그리지 위한 좌표값
    Offset p1 = Offset(200.0, 400.0);
    Offset p2 = Offset(size.width, size.height);

    // 선을 그린다.
    canvas.drawLine(p1, p2, paint);
  }

  // 화면을 새로 그릴지 말지 정한다. 예전 위젯의 좌표값과 비교해 좌표값이 변했을 때
  // 그린다던지 원하는 조건을 줄 수 있다.
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

CustomPainter는 paint(Canvas cavas, Size size)와 shouldRepaint(CustomPainter oldDelegate)로 구현해야 한다.

  • paint()는 화면을 그릴 때 사용된다. 즉, 화면을 새로 그릴때마다 호출된다.
  • shouldRepaint()는 화면 그리기 여부를 정한다. 예전 위젯의 좌표값과 비교해 좌표값이 변했을 때, 그리다던지 원하는 대로 조건을 줄 수 있다.

paint(Canvas canvas, Size size) 메소드는 canvas 객체를 사용하여 화면을 그린다. canvas는 다양한 그리기 함수를 지원한다.

canvas.drawLine(p1, p2, paint);
canvas.drawRect(rect, paint);

canvas는 Paint 클래스를 객체로 받아 화면에 어떤식으로 그릴지 정한다.


CustomPaint : painter와 foregroundPainter

CustomPaint는 위젯의 크기를 결정하고, 어떤 painter를 사용할지 결정한다. painter에는 2종류가 있다.

  1. painter : painter → child 순으로 그려진다.
  2. foregroundPainter : child → foregroundPainter 순으로 그려진다.
CustomPaint(
  ...
  painter: MyPainter(),
  child: Container(
    width: 200,
    height: 200,
    color: Colors.green,
  ),
  ...
)

Container 위젯으로인해 가려져서 보이지 않는다.

CustomPaint(
  ...
  foregroundPainter: MyPainter(),
  child: Container(
    width: 200,
    height: 200,
    color: Colors.green,
  ),
  ...
)

foregroundPainter 속성을 사용하여 그래프를 맨 위로 올려놓아 그래프가 보인다.

foregroundPainter는 child -> foregroundPainter 순으로 그려지므로 그래프가 보이는 것을 확인할 수 있다.


CustomPaint : Widget Size

Widget의 크기는 기본적으로 "부모 → 자식(child) → size(CustomPaint 속성)" 값을 따른다. 부모의 크기를 정하고 그 다음 자식이다. 아무것도 정해지지 않았다면 size값에 따라 위젯의 크기를 정한다. size도 없다면 위젯은 그려지지 않는다.

즉, 부모의 size가 있다면 부모의 size대로 그려지고, 부모 size가 없고 자식 size만 있다면 자식의 size대로 그려진다. 만약 둘 다 없다면 CustomPaint의 size 속성 크기대로 그려진다.

부모, 자식, size 속성이 모두 존재하는 경우

Container(
  width: 300,
  height: 300,
  child: CustomPaint(
    size: size(200, 200),
    ...
    child: Container(
      width: 100,
      height: 100,
      ...
    )
  )
)

부모의 사이즈로 그려진 그래프

부모의 사이즈대로 그려진것을 확인할 수 있다.


자식, size 속성이 존재하는 경우

CustomPaint(
  size: Size(200, 200),
  ...
  child: Container(
    width: 100,
    height: 100,
  )
)

자식의 사이즈로 그려진 그래프

자식의 사이즈대로 그려진것을 확인할 수 있다.


CustomPaint의 size속성만 존재하는 경우

CustomPaint(
  size: Size(200, 200),
)

CustomPaint의 size속성 크기로 그려진 그래프

CustomPaint의 size속성만 존재할 경우 이 크기대로 그래프가 그려지는 것을 확인할 수 있다.


원형 차트 그리기

원을 그릴 때는 drawCircle() 메소드와 drawArc() 메소드를 사용한다. 

  • drawCircle : 정해진 위치에 원를 그릴 때 사용한다.
  • drawArc : 타원이나, arc를 그릴 때 사용한다.
import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyCustomGraph(),
    );
  }
}

class MyCustomGraph extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('custom graph'),
      ),
      body: Container(
        padding: const EdgeInsets.symmetric(vertical: 8.0),
        child: Center(
          child: CustomPaint(
            size: Size(300, 300),
            painter: PieChart(percentage: 35),
          ),
        ),
      ),
    );
  }
}

class PieChart extends CustomPainter {
  final int percentage;
  final double textScaleFactor;

  PieChart({@required this.percentage, this.textScaleFactor = 1.0});

  @override
  void paint(Canvas canvas, Size size) {
    // 화면에 그릴 paint 정의
    Paint paint = Paint()
      ..color = Colors.indigo
      ..strokeWidth = 12.0
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    // 원의 반지름을 구한다. 선의 굵이에 영향을 받지 않게 보정
    double radius = min(size.width / 2 - paint.strokeWidth / 2,
        size.height / 2 - paint.strokeWidth / 2);

    // 그래프가 가운데로 그려지도록 좌표를 정한다.
    Offset center = Offset(size.width / 2, size.height / 2);

    // 원 그래프를 그린다.
    canvas.drawCircle(center, radius, paint);

    // 호(arc)의 각도를 정한다. 정해진 각도만큼만 그린다.
    double arcAngle = 2 * pi * (percentage / 100.0);

    // 호를 그릴때 색 변경
    paint..color = Colors.deepOrangeAccent;

    // 호(arc)를 그린다.
    canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -pi / 2,
        arcAngle, false, paint);

    // 텍스트를 화면에 표시한다.
    drawText(canvas, size, '$percentage / 100');
  }

  // 원의 중앙에 텍스트를 적는다.
  void drawText(Canvas canvas, Size size, String text) {
    double fontSize = getFontSize(size, text);

    TextSpan sp = TextSpan(
        style: TextStyle(
            fontSize: fontSize,
            fontWeight: FontWeight.bold,
            color: Colors.black),
        text: text);

    TextPainter tp = TextPainter(text: sp, textDirection: TextDirection.ltr);

    // 필수로 호출해야 한다. 텍스트 페인터에 그려질 텍스트의 크기와 방향을 결정한다.
    tp.layout();

    double dx = size.width / 2 - tp.width / 2;
    double dy = size.height / 2 - tp.height / 2;

    Offset offset = Offset(dx, dy);
    tp.paint(canvas, offset);
  }

  // 화면 크기에 비례하도록 텍스트 폰트 크기를 정한다.
  double getFontSize(Size size, String text) {
    return size.width / text.length * textScaleFactor;
  }

  // 다르면 다시 그리도록
  @override
  bool shouldRepaint(PieChart old) {
    return old.percentage != percentage;
  }
}

원형 그래프

CustomPainter에 글자를 적으려면 반드시 TextPainter를 사용해야 한다. TextPainter()는 텍스트의 좌표를 정하는데 쓰인다. 

TextPainter를 사용할 땐, 필히 layout() 함수를 호출하여 텍스트의 크기와 방향을 결정해줘야 한다.


라인 차트 그리기

라인 차트는 각각의 값이 점으로 표현되고, 점들이 연결되는 그래프이다. 또한 최저값과 최고값을 표현한다. drawPath() 와 drawPoints() 메소드를 사용한다.

  • drawPath : 선을 그릴 때 사용한다. path에 있는 좌표를 따라 선을 그린다.
  • drawPoints : 점을 그릴 때 사용한다.
import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyCustomGraph(),
    );
  }
}

class MyCustomGraph extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('custom graph'),
      ),
      body: Container(
        padding: const EdgeInsets.symmetric(vertical: 8.0),
        child: Center(
          child: CustomPaint(
            size: Size(300, 300),
            foregroundPainter: LineChart(
              points: <double>[100, 55, 10, 2, 120, 150, 35, 55, 75, 135],
              pointSize: 15.0,
              lineWidth: 5.0,
              lineColor: Colors.deepOrangeAccent,
              pointColor: Colors.green,
            ),
          ),
        ),
      ),
    );
  }
}

class LineChart extends CustomPainter {
  List<double> points;
  final double lineWidth;
  final double pointSize;
  final double fontSize = 18.0;
  final Color lineColor;
  final Color pointColor;
  int maxValueIndex;
  int minValueIndex;

  LineChart(
      {this.points,
      this.pointSize,
      this.lineWidth,
      this.lineColor,
      this.pointColor});

  @override
  void paint(Canvas canvas, Size size) {
    // 점들이 그려질 좌표를 구한다.
    List<Offset> offsets = getCoordinates(points, size);
    // 텍스트를 그린다.
    drawText(canvas, offsets);
    // 좌표를 바탕으로 선을 그린다.
    drawLines(canvas, size, offsets);
    // 좌표에 따라 점을 그린다.
    drawPoints(canvas, size, offsets);
  }

  @override
  bool shouldRepaint(LineChart oldDelegate) {
    return oldDelegate.points != points;
  }

  void drawLines(Canvas canvas, Size size, List<Offset> offsets) {
    Paint paint = Paint()
      ..color = lineColor
      ..strokeWidth = lineWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    Path path = Path();

    double dx = offsets[0].dx;
    double dy = offsets[0].dy;

    path.moveTo(dx, dy);
    offsets.map((offset) => path.lineTo(offset.dx, offset.dy)).toList();

    canvas.drawPath(path, paint);
  }

  void drawPoints(Canvas canvas, Size size, List<Offset> offsets) {
    Paint paint = Paint()
      ..color = pointColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = pointSize;

    canvas.drawPoints(PointMode.points, offsets, paint);
  }

  List<Offset> getCoordinates(List<double> points, Size size) {
    List<Offset> coordinates = <Offset>[];

    // 좌표를 일정 간격으로 벌리기 위한 값
    double spacing = size.width / (points.length - 1);
    // 데이터 중 최소값을 구한다.
    double minY = points.reduce(min);
    // 데이터 중 최대값을 구한다.
    double maxY = points.reduce(max);

    // 텍스트가 들어갈 아래쪽 패딩을 구한다.
    double bottomPadding = fontSize * 2;
    // 텍스트가 들어갈 위쪽 패딩을 구한다.
    double topPadding = bottomPadding * 2;
    // 패딩을 제외한 화면의 높이를 구한다.
    double h = size.height - topPadding;

    for (int index = 0; index < points.length; index++) {
      // x축 좌표를 구한다.
      double x = spacing * index;
      // 정규화한다.
      double normalizedY = points[index] / maxY;
      // y축 좌표를 구한다. 높이에 비례한 값.
      double y = getYPos(h, bottomPadding, normalizedY);

      Offset coord = Offset(x, y);
      coordinates.add(coord);

      findMinIndex(points, index, minY);
      findMaxIndex(points, index, maxY);
    }

    return coordinates;
  }

  double getYPos(double h, double bottomPadding, double normalizedY) =>
      (h + bottomPadding) - (normalizedY * h);

  void findMaxIndex(List<double> points, int index, double maxY) {
    if (maxY == points[index]) {
      maxValueIndex = index;
    }
  }

  void findMinIndex(List<double> points, int index, double minY) {
    if (minY == points[index]) {
      minValueIndex = index;
    }
  }

  void drawTextValue(Canvas canvas, String text, Offset pos, bool textUpward) {
    TextSpan maxSpan = TextSpan(
        style: TextStyle(
            fontSize: fontSize,
            color: Colors.black,
            fontWeight: FontWeight.bold),
        text: text);
    TextPainter tp =
        TextPainter(text: maxSpan, textDirection: TextDirection.ltr);
    tp.layout();

    // 텍스트의 방향을 고려해 y축 값을 보정한다.
    double y = textUpward ? -tp.height * 1.5 : tp.height * 0.5;
    // 텍스트의 위치를 고려해 x축 값을 보정한다.
    double dx = pos.dx - tp.width / 2;
    double dy = pos.dy + y;

    Offset offset = Offset(dx, dy);

    tp.paint(canvas, offset);
  }

  void drawText(Canvas canvas, List<Offset> offsets) {
    String minValue = points.reduce(min).toString();
    String maxValue = points.reduce(max).toString();

    drawTextValue(canvas, minValue, offsets[minValueIndex], false);
    drawTextValue(canvas, maxValue, offsets[maxValueIndex], true);
  }
}

위의 코드는 크게 4개의 메소드로 구성되어 있다.

  1. getCoordinates() : 좌표 구하기
  2. drawText() : 텍스트 그리기
  3. drawLines(): 선 그리기
  4. drawPoints() : 점 그리기

막대(Bar) 차트 그리기

막대 차트는 가로와 세로에 텍스트를 적을 수 있다. 연도별 제품 판매량 혹은 생산량 등 그래프로 표현하기에 알맞은 차트이다.

막대 차트는 3부분으로 구성되어 있다.

  1. 좌표를 구하는 부분 : 어느곳에다 그래프를 구를지 정한다.
  2. 그래프를 그리는 부분 : 실제 막대 그래프를 그린다. 그래프를 그릴 때는 drawRect() 함수를 사용한다.
  3. 텍스트를 그리는 부분 : x축과 y축에 텍스트를 그린다. 
import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyCustomGraph(),
    );
  }
}

class MyCustomGraph extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('custom graph'),
      ),
      body: Container(
        padding: const EdgeInsets.symmetric(vertical: 8.0),
        child: Center(
          child: CustomPaint(
            size: Size(300, 300),
            foregroundPainter: BarChart(
              data: <double>[105, 55, 99, 150, 300, 500, 120, 1000, 1800],
              labels: <String>[
                '2013',
                '2014',
                '2015',
                '2016',
                '2017',
                '2018',
                '2019',
                '2020',
                '2021'
              ],
              color: Colors.deepOrange,
            ),
          ),
        ),
      ),
    );
  }
}

class BarChart extends CustomPainter {
  final Color color;
  final List<double> data;
  final List<String> labels;
  double bottomPadding = 0.0;
  double leftPadding = 0.0;
  double textScaleFactorXAxis = 1.0;
  double textScaleFactorYAxis = 1.2;

  BarChart({this.data, this.labels, this.color = Colors.blue});

  @override
  void paint(Canvas canvas, Size size) {
    // 텍스트 공간을 미리 정한다.
    setTextPadding(size);

    List<Offset> coordinates = getCoordinates(size);

    drawBar(canvas, size, coordinates);
    drawXLabels(canvas, size, coordinates);
    drawYLabels(canvas, size, coordinates);
    drawLines(canvas, size, coordinates);
  }

  @override
  bool shouldRepaint(BarChart oldDelegate) {
    return oldDelegate.data != data;
  }

  void setTextPadding(Size size) {
    // 세로 크기의 1/10만큼 텍스트 패딩
    bottomPadding = size.height / 10;
    // 가로 길이 1/10만큼 텍스트 패딩
    leftPadding = size.width / 10;
  }

  void drawBar(Canvas canvas, Size size, List<Offset> coordinates) {
    Paint paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill
      ..strokeCap = StrokeCap.round;

    // 막대 그래프가 겹치지 않게 간격을 준다.
    double barWidthMargin = size.width * 0.09;

    for (int index = 0; index < coordinates.length; index++) {
      Offset offset = coordinates[index];
      double left = offset.dx;
      // 간격만큼 가로로 이동
      double right = offset.dx + barWidthMargin;
      double top = offset.dy;
      // 텍스트 크기만큼 패딩을 빼준다. 그래서 텍스트와 겹치지 않게 한다.
      double bottom = size.height - bottomPadding;

      Rect rect = Rect.fromLTRB(left, top, right, bottom);
      canvas.drawRect(rect, paint);
    }
  }

  // x축 텍스트(레이블)를 그린다.
  void drawXLabels(Canvas canvas, Size size, List<Offset> coordinates) {
    // 화면 크기에 따라 유동적으로 폰트 크기를 계산한다.
    double fontSize = calculateFontSize(labels[0], size, xAxis: true);

    for (int index = 0; index < labels.length; index++) {
      TextSpan span = TextSpan(
        style: TextStyle(
          color: Colors.black,
          fontSize: fontSize,
          fontFamily: 'Roboto',
          fontWeight: FontWeight.w400,
        ),
        text: labels[index],
      );
      TextPainter tp =
          TextPainter(text: span, textDirection: TextDirection.ltr);
      tp.layout();

      Offset offset = coordinates[index];
      double dx = offset.dx;
      double dy = size.height - tp.height;

      tp.paint(canvas, Offset(dx, dy));
    }
  }

  // Y축 텍스트(레이블)를 그린다. 최저값과 최고값을 Y축에 표시한다.
  void drawYLabels(Canvas canvas, Size size, List<Offset> coordinates) {
    double bottomY = coordinates[0].dy;
    double topY = coordinates[0].dy;
    int indexOfMin = 0;
    int indexOfMax = 0;

    for (int index = 0; index < coordinates.length; index++) {
      double dy = coordinates[index].dy;
      if (bottomY < dy) {
        bottomY = dy;
        indexOfMin = index;
      }
      if (topY > dy) {
        topY = dy;
        indexOfMax = index;
      }
    }
    String minValue = '${data[indexOfMin].toInt()}';
    String maxValue = '${data[indexOfMax].toInt()}';

    double fontSize = calculateFontSize(maxValue, size, xAxis: false);

    drawYText(canvas, minValue, fontSize, bottomY);
    drawYText(canvas, maxValue, fontSize, topY);
  }

  // 화면 크기에 비례해 폰트 크기를 계산한다.
  double calculateFontSize(String value, Size size, {bool xAxis}) {
    // 글자수에 따라 폰트 크기를 계산하기 위함
    int numberOfCharacters = value.length;
    // width가 600일 때 100글자를 적어야 한다면, fontSize는 글자 하나당 6이어야 한다.
    double fontSize = (size.width / numberOfCharacters) / data.length;

    if (xAxis) {
      fontSize *= textScaleFactorXAxis;
    } else {
      fontSize *= textScaleFactorYAxis;
    }
    return fontSize;
  }

  // x축 & y축 구분하는 선을 그린다.
  void drawLines(Canvas canvas, Size size, List<Offset> coordinates) {
    Paint paint = Paint()
      ..color = Colors.blueGrey[100]
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.8;

    double bottom = size.height - bottomPadding;
    double left = coordinates[0].dx;

    Path path = Path();
    path.moveTo(left, 0);
    path.lineTo(left, bottom);
    path.lineTo(size.width, bottom);

    canvas.drawPath(path, paint);
  }

  void drawYText(Canvas canvas, String text, double fontSize, double y) {
    TextSpan span = TextSpan(
      style: TextStyle(
        fontSize: fontSize,
        color: Colors.black,
        fontFamily: 'Roboto',
        fontWeight: FontWeight.w600,
      ),
      text: text,
    );
    TextPainter tp = TextPainter(text: span, textDirection: TextDirection.ltr);

    tp.layout();

    Offset offset = Offset(0.0, y);
    tp.paint(canvas, offset);
  }

  List<Offset> getCoordinates(Size size) {
    List<Offset> coordinates = <Offset>[];

    double maxData = data.reduce(max);

    double width = size.width - leftPadding;
    double minBarWidth = width / data.length;

    for (int index = 0; index < data.length; index++) {
      // 그래프의 가로 위치를 정한다.
      double left = minBarWidth * (index) + leftPadding;
      // 그래프의 높이가 [0~1] 사이가 되도록 정규화 한다.
      double normalized = data[index] / maxData;
      // x축에 표시되는 글자들과 겹치지 않게 높이에서 패딩을 제외한다.
      double height = size.height - bottomPadding;
      // 정규화된 값을 통해 높이를 구한다.
      double top = height - normalized * height;

      Offset offset = Offset(left, top);
      coordinates.add(offset);
    }

    return coordinates;
  }
}

Comments