From 2e558d8596b464b821796bccf1f66d095e47fa2f Mon Sep 17 00:00:00 2001 From: Victor Choueiri Date: Fri, 3 Nov 2017 01:48:58 +0200 Subject: [PATCH] Initial commit --- .gitignore | 7 + CHANGELOG.md | 3 + LICENSE | 1 + README.md | 9 ++ flutter_circular_chart.iml | 13 ++ lib/flutter_circular_chart.dart | 5 + lib/src/animated_circular_chart.dart | 201 ++++++++++++++++++++++++++ lib/src/circular_chart.dart | 65 +++++++++ lib/src/entry.dart | 20 +++ lib/src/painter.dart | 60 ++++++++ lib/src/segment.dart | 45 ++++++ lib/src/stack.dart | 85 +++++++++++ lib/src/tween.dart | 39 +++++ pubspec.yaml | 49 +++++++ test/flutter_circular_chart_test.dart | 13 ++ 15 files changed, 615 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 flutter_circular_chart.iml create mode 100644 lib/flutter_circular_chart.dart create mode 100644 lib/src/animated_circular_chart.dart create mode 100644 lib/src/circular_chart.dart create mode 100644 lib/src/entry.dart create mode 100644 lib/src/painter.dart create mode 100644 lib/src/segment.dart create mode 100644 lib/src/stack.dart create mode 100644 lib/src/tween.dart create mode 100644 pubspec.yaml create mode 100644 test/flutter_circular_chart_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75e9658 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.atom/ +.idea +.packages +.pub/ +packages +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac07159 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - TODO: Add release date. + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..834e536 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# flutter_circular_chart + +Animated radial and pie charts for Flutter + +## Getting Started + +For help getting started with Flutter, view our online [documentation](http://flutter.io/). + +For help on editing package code, view the [documentation](https://flutter.io/developing-packages/). diff --git a/flutter_circular_chart.iml b/flutter_circular_chart.iml new file mode 100644 index 0000000..27915e5 --- /dev/null +++ b/flutter_circular_chart.iml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/flutter_circular_chart.dart b/lib/flutter_circular_chart.dart new file mode 100644 index 0000000..676c208 --- /dev/null +++ b/lib/flutter_circular_chart.dart @@ -0,0 +1,5 @@ +library flutter_circular_chart; + +export 'src/circular_chart.dart'; +export 'src/animated_circular_chart.dart'; +export 'src/entry.dart'; diff --git a/lib/src/animated_circular_chart.dart b/lib/src/animated_circular_chart.dart new file mode 100644 index 0000000..46850cf --- /dev/null +++ b/lib/src/animated_circular_chart.dart @@ -0,0 +1,201 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_circular_chart/src/circular_chart.dart'; +import 'package:flutter_circular_chart/src/entry.dart'; +import 'package:flutter_circular_chart/src/painter.dart'; + +// The default chart tween animation duration. +const Duration _kDuration = const Duration(milliseconds: 300); +// The default angle the chart is oriented at. +const double _kStartAngle = -90.0; + +enum CircularChartType { + Pie, + Radial, +} + +class AnimatedCircularChart extends StatefulWidget { + AnimatedCircularChart({ + Key key, + @required this.size, + @required this.initialChartData, + this.chartType = CircularChartType.Radial, + this.duration = _kDuration, + this.percentageValues = false, + this.holeRadius, + this.startAngle = _kStartAngle, + }) + : assert(size != null), + super(key: key); + + /// The size of the bounding box this chart will be constrained to. + final Size size; + + /// The data used to build the chart displayed when the widget is first placed. + /// Each [CircularStackEntry] in the list defines an individual stack of data: + /// For a Pie chart that corresponds to individual slices in the chart. + /// For a Radial chart it corresponds to individual segments on the same arc. + /// + /// If length > 1 and [chartType] is [CircularChartType.Radial] then the stacks + /// will be grouped together as concentric circles. + /// + /// If [chartType] is [CircularChartType.Pie] then length cannot be > 1. + final List initialChartData; + + /// The type of chart to be rendered. + /// Use [CircularChartType.Pie] for a circle divided into slices for each entry. + /// Use [CircularChartType.Radial] for one or more arcs with a hole in the center. + final CircularChartType chartType; + + /// The duration of the chart animation when [AnimatedCircularChartState.updateData] + /// is called. + final Duration duration; + + /// If true then the data values provided will determine what percentage of the circle + /// this segment occupies [i.e: a value of 100 is the full circle]. + /// + /// Otherwise the data is normalized such that the sum of all values in each stack + /// is considered to encompass 100% of the circle. + /// + /// defaults to false. + final bool percentageValues; + + /// For [CircularChartType.Radial] charts this defines the circle in the center + /// of the canvas, around which the chart is drawn. If not provided then it will + /// be automatically calculated to accommodate all the data. + /// + /// Has no effect in [CircularChartType.Pie] charts. + final double holeRadius; + + /// The chart gets drawn and animates clockwise from [startAngle], defaulting to the + /// top/center point or -90.0. In terms of a clock face these would be: + /// - -90.0: 12 o'clock + /// - 0.0: 3 o'clock + /// - 90.0: 6 o'clock + /// - 180.0: 9 o'clock + final double startAngle; + + /// The state from the closest instance of this class that encloses the given context. + /// + /// This method is typically used by [AnimatedCircularChart] item widgets that insert or + /// remove items in response to user input. + /// + /// ```dart + /// AnimatedCircularChartState animatedCircularChart = AnimatedCircularChart.of(context); + /// ``` + static AnimatedCircularChartState of(BuildContext context, + {bool nullOk: false}) { + assert(context != null); + assert(nullOk != null); + + final AnimatedCircularChartState result = context + .ancestorStateOfType(const TypeMatcher()); + + if (nullOk || result != null) return result; + + throw new FlutterError( + 'AnimatedCircularChart.of() called with a context that does not contain a AnimatedCircularChart.\n' + 'No AnimatedCircularChart ancestor could be found starting from the context that was passed to AnimatedCircularChart.of(). ' + 'This can happen when the context provided is from the same StatefulWidget that ' + 'built the AnimatedCircularChart.\n' + 'The context used was:\n' + ' $context'); + } + + @override + AnimatedCircularChartState createState() => new AnimatedCircularChartState(); +} + +/// The state for a circular chart that animates when its data is updated. +/// +/// When the chart data changes with [updateData] an animation begins running. +/// +/// An app that needs to update its data in response to an event +/// can refer to the [AnimatedCircularChart]'s state with a global key: +/// +/// ```dart +/// GlobalKey chartKey = new GlobalKey(); +/// ... +/// new AnimatedCircularChart(key: chartKey, ...); +/// ... +/// chartKey.currentState.updateData(newData); +/// ``` +class AnimatedCircularChartState extends State + with TickerProviderStateMixin { + CircularChartTween _tween; + AnimationController _animation; + final Map _stackRanks = {}; + final Map _entryRanks = {}; + + @override + void initState() { + super.initState(); + _animation = new AnimationController( + duration: widget.duration, + vsync: this, + ); + + _assignRanks(widget.initialChartData); + + _tween = new CircularChartTween( + new CircularChart.empty(chartType: widget.chartType), + new CircularChart.fromData( + size: widget.size, + data: widget.initialChartData, + chartType: widget.chartType, + stackRanks: _stackRanks, + entryRanks: _entryRanks, + percentageValues: widget.percentageValues, + holeRadius: widget.holeRadius, + startAngle: widget.startAngle, + ), + ); + _animation.forward(); + } + + @override + void dispose() { + _animation.dispose(); + super.dispose(); + } + + void _assignRanks(List data) { + for (CircularStackEntry stackEntry in data) { + _stackRanks.putIfAbsent(stackEntry.rankKey, () => _stackRanks.length); + for (CircularSegmentEntry entry in stackEntry.entries) { + _entryRanks.putIfAbsent(entry.rankKey, () => _entryRanks.length); + } + } + } + + /// Update the data this chart represents and start an animation that will tween + /// between the old data and this one. + void updateData(List data) { + _assignRanks(data); + + setState(() { + _tween = new CircularChartTween( + _tween.evaluate(_animation), + new CircularChart.fromData( + size: widget.size, + data: data, + chartType: widget.chartType, + stackRanks: _stackRanks, + entryRanks: _entryRanks, + percentageValues: widget.percentageValues, + holeRadius: widget.holeRadius, + startAngle: widget.startAngle, + ), + ); + _animation.forward(from: 0.0); + }); + } + + @override + Widget build(BuildContext context) { + return new CustomPaint( + size: widget.size, + painter: new AnimatedCircularChartPainter(_tween.animate(_animation)), + ); + } +} diff --git a/lib/src/circular_chart.dart b/lib/src/circular_chart.dart new file mode 100644 index 0000000..7243e63 --- /dev/null +++ b/lib/src/circular_chart.dart @@ -0,0 +1,65 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_circular_chart/src/animated_circular_chart.dart'; +import 'package:flutter_circular_chart/src/entry.dart'; +import 'package:flutter_circular_chart/src/stack.dart'; +import 'package:flutter_circular_chart/src/tween.dart'; + +class CircularChart { + static const double _kStackWidthFraction = 0.75; + + CircularChart(this.stacks, this.chartType); + + factory CircularChart.empty({@required CircularChartType chartType}) { + return new CircularChart([], chartType); + } + + final List stacks; + final CircularChartType chartType; + + factory CircularChart.fromData({ + @required Size size, + @required List data, + @required CircularChartType chartType, + @required bool percentageValues, + @required double startAngle, + Map stackRanks, + Map entryRanks, + double holeRadius, + }) { + final double _holeRadius = holeRadius ?? size.width / (2 + data.length); + final double stackDistance = + (size.width / 2 - _holeRadius) / (2 + data.length); + final double stackWidth = stackDistance * _kStackWidthFraction; + final double startRadius = stackDistance + _holeRadius; + + List stacks = new List.generate( + data.length, + (i) => new CircularChartStack.fromData( + stackRanks[data[i].rankKey] ?? i, + data[i].entries, + entryRanks, + percentageValues, + startRadius + i * stackDistance, + stackWidth, + startAngle, + ), + ); + + return new CircularChart(stacks, chartType); + } +} + +class CircularChartTween extends Tween { + CircularChartTween(CircularChart begin, CircularChart end) + : _stacksTween = + new MergeTween(begin.stacks, end.stacks), + super(begin: begin, end: end); + + final MergeTween _stacksTween; + + @override + CircularChart lerp(double t) => + new CircularChart(_stacksTween.lerp(t), begin.chartType); +} diff --git a/lib/src/entry.dart b/lib/src/entry.dart new file mode 100644 index 0000000..7005a17 --- /dev/null +++ b/lib/src/entry.dart @@ -0,0 +1,20 @@ +import 'dart:ui'; + +class CircularSegmentEntry { + const CircularSegmentEntry(this.value, this.color, {this.rankKey}); + + final double value; + final Color color; + final String rankKey; + + String toString() { + return '$rankKey: $value $color'; + } +} + +class CircularStackEntry { + const CircularStackEntry(this.entries, {this.rankKey}); + + final List entries; + final String rankKey; +} diff --git a/lib/src/painter.dart b/lib/src/painter.dart new file mode 100644 index 0000000..64dfa85 --- /dev/null +++ b/lib/src/painter.dart @@ -0,0 +1,60 @@ +import 'dart:math' as Math; + +import 'package:flutter/material.dart'; +import 'package:flutter_circular_chart/src/animated_circular_chart.dart'; +import 'package:flutter_circular_chart/src/circular_chart.dart'; +import 'package:flutter_circular_chart/src/stack.dart'; + +class AnimatedCircularChartPainter extends CustomPainter { + AnimatedCircularChartPainter(this.animation) : super(repaint: animation); + + final Animation animation; + + @override + void paint(Canvas canvas, Size size) { + _paintChart(canvas, size, animation.value); + } + + @override + bool shouldRepaint(AnimatedCircularChartPainter old) => false; +} + +class CircularChartPainter extends CustomPainter { + CircularChartPainter(this.chart); + + final CircularChart chart; + + @override + void paint(Canvas canvas, Size size) { + _paintChart(canvas, size, chart); + } + + @override + bool shouldRepaint(CircularChartPainter old) => false; +} + +const double _kRadiansPerDegree = Math.PI / 180; + +void _paintChart(Canvas canvas, Size size, CircularChart chart) { + final Paint segmentPaint = new Paint() + ..style = chart.chartType == CircularChartType.Radial + ? PaintingStyle.stroke + : PaintingStyle.fill; + + for (final CircularChartStack stack in chart.stacks) { + for (final segment in stack.segments) { + segmentPaint.color = segment.color; + segmentPaint.strokeWidth = stack.width; + canvas.drawArc( + new Rect.fromCircle( + center: new Offset(size.width / 2, size.height / 2), + radius: stack.radius, + ), + stack.startAngle * _kRadiansPerDegree, + segment.sweepAngle * _kRadiansPerDegree, + chart.chartType == CircularChartType.Pie, + segmentPaint, + ); + } + } +} diff --git a/lib/src/segment.dart b/lib/src/segment.dart new file mode 100644 index 0000000..c6486e2 --- /dev/null +++ b/lib/src/segment.dart @@ -0,0 +1,45 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_circular_chart/src/tween.dart'; + +class CircularChartSegment extends MergeTweenable { + CircularChartSegment(this.rank, this.sweepAngle, this.color); + + final int rank; + final double sweepAngle; + final Color color; + + @override + CircularChartSegment get empty => new CircularChartSegment(rank, 0.0, color); + + @override + bool operator <(CircularChartSegment other) => rank < other.rank; + + @override + Tween tweenTo(CircularChartSegment other) => + new CircularChartSegmentTween(this, other); + + static CircularChartSegment lerp( + CircularChartSegment begin, CircularChartSegment end, double t) { + assert(begin.rank == end.rank); + + return new CircularChartSegment( + begin.rank, + lerpDouble(begin.sweepAngle, end.sweepAngle, t), + Color.lerp(begin.color, end.color, t), + ); + } +} + +class CircularChartSegmentTween extends Tween { + CircularChartSegmentTween( + CircularChartSegment begin, CircularChartSegment end) + : super(begin: begin, end: end) { + assert(begin.rank == end.rank); + } + + @override + CircularChartSegment lerp(double t) => + CircularChartSegment.lerp(begin, end, t); +} diff --git a/lib/src/stack.dart b/lib/src/stack.dart new file mode 100644 index 0000000..627eb85 --- /dev/null +++ b/lib/src/stack.dart @@ -0,0 +1,85 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter_circular_chart/src/entry.dart'; +import 'package:flutter_circular_chart/src/segment.dart'; +import 'package:flutter_circular_chart/src/tween.dart'; + +const double _kMaxAngle = 360.0; + +class CircularChartStack implements MergeTweenable { + CircularChartStack( + this.rank, this.radius, this.width, this.startAngle, this.segments); + + final int rank; + final double radius; + final double width; + final double startAngle; + final List segments; + + factory CircularChartStack.fromData( + int stackRank, + List entries, + Map entryRanks, + bool percentageValues, + double startRadius, + double stackWidth, + double startAngle, + ) { + final double valueSum = percentageValues + ? 100.0 + : entries.fold( + 0.0, + (double prev, CircularSegmentEntry element) => + prev + element.value); + + double previousSweepAngle = 0.0; + List segments = + new List.generate(entries.length, (i) { + double sweepAngle = + (entries[i].value / valueSum * _kMaxAngle) + previousSweepAngle; + previousSweepAngle = sweepAngle; + int rank = entryRanks[entries[i].rankKey] ?? i; + return new CircularChartSegment(rank, sweepAngle, entries[i].color); + }); + + return new CircularChartStack( + stackRank, + startRadius, + stackWidth, + startAngle, + segments.reversed.toList(), + ); + } + + @override + CircularChartStack get empty => new CircularChartStack( + rank, radius, 0.0, startAngle, []); + + @override + bool operator <(CircularChartStack other) => rank < other.rank; + + @override + Tween tweenTo(CircularChartStack other) => + new CircularChartStackTween(this, other); +} + +class CircularChartStackTween extends Tween { + CircularChartStackTween(CircularChartStack begin, CircularChartStack end) + : _circularSegmentsTween = + new MergeTween(begin.segments, end.segments), + super(begin: begin, end: end) { + assert(begin.rank == end.rank); + } + + final MergeTween _circularSegmentsTween; + + @override + CircularChartStack lerp(double t) => new CircularChartStack( + begin.rank, + lerpDouble(begin.radius, end.radius, t), + lerpDouble(begin.width, end.width, t), + lerpDouble(begin.startAngle, end.startAngle, t), + _circularSegmentsTween.lerp(t), + ); +} diff --git a/lib/src/tween.dart b/lib/src/tween.dart new file mode 100644 index 0000000..9debe26 --- /dev/null +++ b/lib/src/tween.dart @@ -0,0 +1,39 @@ +import 'package:flutter/animation.dart'; + +abstract class MergeTweenable { + T get empty; + + Tween tweenTo(T other); + + bool operator <(T other); +} + +class MergeTween> extends Tween> { + MergeTween(List begin, List end) : super(begin: begin, end: end) { + final bMax = begin.length; + final eMax = end.length; + var b = 0; + var e = 0; + while (b + e < bMax + eMax) { + if (b < bMax && (e == eMax || begin[b] < end[e])) { + _tweens.add(begin[b].tweenTo(begin[b].empty)); + b++; + } else if (e < eMax && (b == bMax || end[e] < begin[b])) { + _tweens.add(end[e].empty.tweenTo(end[e])); + e++; + } else { + _tweens.add(begin[b].tweenTo(end[e])); + b++; + e++; + } + } + } + + final _tweens = >[]; + + @override + List lerp(double t) => new List.generate( + _tweens.length, + (i) => _tweens[i].lerp(t), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..757512f --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,49 @@ +name: flutter_circular_chart +description: Animated radial and pie charts for Flutter +version: 0.0.1 +author: +homepage: + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + test: ^0.12.0 + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.io/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.io/custom-fonts/#from-packages \ No newline at end of file diff --git a/test/flutter_circular_chart_test.dart b/test/flutter_circular_chart_test.dart new file mode 100644 index 0000000..0dfb66a --- /dev/null +++ b/test/flutter_circular_chart_test.dart @@ -0,0 +1,13 @@ +import 'package:test/test.dart'; + +import 'package:flutter_circular_chart/flutter_circular_chart.dart'; + +void main() { + test('adds one to input values', () { + final calculator = new Calculator(); + expect(calculator.addOne(2), 3); + expect(calculator.addOne(-7), -6); + expect(calculator.addOne(0), 1); + expect(() => calculator.addOne(null), throwsNoSuchMethodError); + }); +}