diff --git a/example/lib/presentation/samples/chart_samples.dart b/example/lib/presentation/samples/chart_samples.dart index 1f2d34c84..f0e5a59a5 100644 --- a/example/lib/presentation/samples/chart_samples.dart +++ b/example/lib/presentation/samples/chart_samples.dart @@ -31,6 +31,7 @@ import 'pie/pie_chart_sample1.dart'; import 'pie/pie_chart_sample2.dart'; import 'pie/pie_chart_sample3.dart'; import 'pie/pie_chart_sample4.dart'; +import 'pie/pie_chart_sample5.dart'; import 'radar/radar_chart_sample1.dart'; import 'scatter/scatter_chart_sample1.dart'; import 'scatter/scatter_chart_sample2.dart'; @@ -67,6 +68,7 @@ class ChartSamples { PieChartSample(2, (context) => const PieChartSample2()), PieChartSample(3, (context) => const PieChartSample3()), PieChartSample(4, (context) => const PieChartSample4()), + PieChartSample(5, (context) => const PieChartSample5()), ], ChartType.scatter: [ ScatterChartSample(1, (context) => ScatterChartSample1()), diff --git a/example/lib/presentation/samples/pie/pie_chart_sample5.dart b/example/lib/presentation/samples/pie/pie_chart_sample5.dart new file mode 100644 index 000000000..cbdfe57ea --- /dev/null +++ b/example/lib/presentation/samples/pie/pie_chart_sample5.dart @@ -0,0 +1,126 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart_app/presentation/resources/app_colors.dart'; +import 'package:flutter/material.dart'; + +class PieChartSample5 extends StatefulWidget { + const PieChartSample5({super.key}); + + @override + State createState() => _PieChartSample5State(); +} + +class _PieChartSample5State extends State { + int touchedIndex = -1; + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1.3, + child: AspectRatio( + aspectRatio: 1, + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + touchedIndex = -1; + return; + } + touchedIndex = + pieTouchResponse.touchedSection!.touchedSectionIndex; + }); + }, + ), + borderData: FlBorderData(show: false), + sectionsSpace: 3, + centerSpaceRadius: 24, + sections: showingSections(), + ), + ), + ), + ); + } + + List showingSections() { + return List.generate(4, (i) { + final isTouched = i == touchedIndex; + final radius = 56.0; + final radialOffset = isTouched ? 12.0 : 0.0; + final fontSize = isTouched ? 18.0 : 14.0; + const shadows = [Shadow(color: Colors.black, blurRadius: 2)]; + + return switch (i) { + 0 => PieChartSectionData( + color: AppColors.contentColorBlue, + value: 40, + title: '40%', + radius: radius, + radialOffset: radialOffset, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.contentColorWhite, + shadows: shadows, + ), + segments: [ + PieChartStackSegmentData( + fromRadius: 0, + toRadius: radius, + color: AppColors.contentColorBlue, + ), + ], + ), + 1 => PieChartSectionData( + color: AppColors.contentColorOrange, + value: 30, + title: '30%', + radius: radius, + radialOffset: radialOffset, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.contentColorWhite, + shadows: shadows, + ), + ), + 2 => PieChartSectionData( + color: AppColors.contentColorPurple, + value: 20, + title: '20%', + radius: radius, + radialOffset: radialOffset, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.contentColorWhite, + shadows: shadows, + ), + segments: [ + PieChartStackSegmentData( + fromRadius: 0, + toRadius: radius, + color: AppColors.contentColorPurple, + ), + ], + ), + 3 => PieChartSectionData( + color: AppColors.contentColorGreen, + value: 10, + title: '10%', + radius: radius, + radialOffset: radialOffset, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.contentColorWhite, + shadows: shadows, + ), + ), + _ => throw StateError('Invalid'), + }; + }); + } +} diff --git a/lib/src/chart/pie_chart/pie_chart_data.dart b/lib/src/chart/pie_chart/pie_chart_data.dart index ee888a1d8..862ffdea7 100644 --- a/lib/src/chart/pie_chart/pie_chart_data.dart +++ b/lib/src/chart/pie_chart/pie_chart_data.dart @@ -157,6 +157,7 @@ class PieChartSectionData with EquatableMixin { Color? color, this.gradient, double? radius, + double? radialOffset, bool? showTitle, this.titleStyle, String? title, @@ -169,6 +170,7 @@ class PieChartSectionData with EquatableMixin { }) : value = value ?? 10, color = color ?? Colors.cyan, radius = (radius ?? 40).clamp(0, double.infinity).toDouble(), + radialOffset = radialOffset ?? 0, showTitle = showTitle ?? true, title = title ?? (value == null ? '' : value.toString()), borderSide = borderSide ?? const BorderSide(width: 0), @@ -195,6 +197,13 @@ class PieChartSectionData with EquatableMixin { /// Defines the radius of section. final double radius; + /// Additional radial translation applied to the whole section (in logical pixels). + /// Positive values move the section outward along its center angle. + /// + /// Note: This parameter is ignored when there is only a single section + /// occupying 360 degrees, as there is no meaningful direction to offset. + final double radialOffset; + /// Defines show or hide the title of section. final bool showTitle; @@ -244,6 +253,7 @@ class PieChartSectionData with EquatableMixin { Color? color, Gradient? gradient, double? radius, + double? radialOffset, bool? showTitle, TextStyle? titleStyle, String? title, @@ -259,6 +269,7 @@ class PieChartSectionData with EquatableMixin { color: color ?? this.color, gradient: gradient ?? this.gradient, radius: radius ?? this.radius, + radialOffset: radialOffset ?? this.radialOffset, showTitle: showTitle ?? this.showTitle, titleStyle: titleStyle ?? this.titleStyle, title: title ?? this.title, @@ -283,6 +294,7 @@ class PieChartSectionData with EquatableMixin { color: Color.lerp(a.color, b.color, t), gradient: Gradient.lerp(a.gradient, b.gradient, t), radius: lerpDouble(a.radius, b.radius, t), + radialOffset: lerpDouble(a.radialOffset, b.radialOffset, t), showTitle: b.showTitle, titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), title: b.title, @@ -313,6 +325,7 @@ class PieChartSectionData with EquatableMixin { color, gradient, radius, + radialOffset, showTitle, titleStyle, title, diff --git a/lib/src/chart/pie_chart/pie_chart_painter.dart b/lib/src/chart/pie_chart/pie_chart_painter.dart index 07e765f78..8b4128008 100644 --- a/lib/src/chart/pie_chart/pie_chart_painter.dart +++ b/lib/src/chart/pie_chart/pie_chart_painter.dart @@ -109,6 +109,14 @@ class PieChartPainter extends BaseChartPainter { } final sectionDegree = sectionsAngle[i]; + final sectionCenterAngle = tempAngle + (sectionDegree / 2); + final radialOffset = _computeRadialOffset( + section.radialOffset, + sectionCenterAngle, + sectionDegree, + ); + final sectionCenter = center + radialOffset; + if (sectionDegree == 360) { final fullCirclePath = generateSegmentPath( center, @@ -124,7 +132,7 @@ class PieChartPainter extends BaseChartPainter { sectionDegree, centerRadius, 0, - center, + sectionCenter, ); _sectionPaint.blendMode = BlendMode.srcOver; @@ -136,14 +144,14 @@ class PieChartPainter extends BaseChartPainter { // Outer canvasWrapper ..drawCircle( - center, + sectionCenter, centerRadius + section.radius - (section.borderSide.width / 2), _sectionStrokePaint, ) // Inner ..drawCircle( - center, + sectionCenter, centerRadius + (section.borderSide.width / 2), _sectionStrokePaint, ); @@ -158,7 +166,7 @@ class PieChartPainter extends BaseChartPainter { data.sectionsSpace, tempAngle, sectionDegree, - center, + sectionCenter, centerRadius, ); @@ -173,7 +181,7 @@ class PieChartPainter extends BaseChartPainter { sectionDegree, centerRadius, tempAngle, - center, + sectionCenter, ); canvasWrapper.restore(); @@ -723,8 +731,15 @@ class PieChartPainter extends BaseChartPainter { } } + final radialOffset = _computeRadialOffset( + section.radialOffset, + sectionCenterAngle, + sweepAngle, + ); + Offset sectionCenter(double percentageOffset) => center + + radialOffset + Offset( math.cos(Utils().radians(sectionCenterAngle)) * (centerRadius + (section.radius * percentageOffset)), @@ -821,12 +836,19 @@ class PieChartPainter extends BaseChartPainter { break; } + final sectionCenterAngle = tempAngle + (sectionAngle / 2); + final radialOffset = _computeRadialOffset( + section.radialOffset, + sectionCenterAngle, + sectionAngle, + ); + final sectionPath = generateSectionPath( section, data.sectionsSpace, tempAngle, sectionAngle, - center, + center + radialOffset, centerRadius, ); @@ -870,8 +892,15 @@ class PieChartPainter extends BaseChartPainter { final sectionCenterAngle = startAngle + (sweepAngle / 2); final centerRadius = calculateCenterRadius(viewSize, holder); + final radialOffset = _computeRadialOffset( + section.radialOffset, + sectionCenterAngle, + sweepAngle, + ); + Offset sectionCenter(double percentageOffset) => center + + radialOffset + Offset( math.cos(Utils().radians(sectionCenterAngle)) * (centerRadius + (section.radius * percentageOffset)), @@ -889,4 +918,18 @@ class PieChartPainter extends BaseChartPainter { return badgeWidgetsOffsets; } + + /// Computes the radial offset translation for a section along its center angle. + /// Returns [Offset.zero] when [sectionDegree] is 360 (single full-circle section). + Offset _computeRadialOffset( + double radialOffset, + double sectionCenterAngle, + double sectionDegree, + ) { + if (sectionDegree == 360) return Offset.zero; + return Offset( + math.cos(Utils().radians(sectionCenterAngle)) * radialOffset, + math.sin(Utils().radians(sectionCenterAngle)) * radialOffset, + ); + } } diff --git a/repo_files/documentations/pie_chart.md b/repo_files/documentations/pie_chart.md index f61af96f3..1ebb5211b 100644 --- a/repo_files/documentations/pie_chart.md +++ b/repo_files/documentations/pie_chart.md @@ -37,6 +37,7 @@ When you change the chart's state, it animates to the new state internally (usin |color| colors the section| Colors.red| |gradient| You can use any [Gradient](https://api.flutter.dev/flutter/dart-ui/Gradient-class.html) here. such as [LinearGradient](https://api.flutter.dev/flutter/painting/LinearGradient-class.html) or [RadialGradient](https://api.flutter.dev/flutter/painting/RadialGradient-class.html) (you have to provide either `color` or `gradient`)|null| |radius| the width radius of each section|40| +|radialOffset| radial translation (in logical pixels) applied to the whole section, moving it outward along its center angle. Useful for "exploded" pie charts. Ignored when there is only a single section (360 degrees)|0| |showTitle| determines whether to show or hide the titles on each section|true| |titleStyle| TextStyle of the titles| TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)| |title| title of the section| value| diff --git a/test/chart/pie_chart/pie_chart_data_test.dart b/test/chart/pie_chart/pie_chart_data_test.dart index d1f368f27..0e81b6948 100644 --- a/test/chart/pie_chart/pie_chart_data_test.dart +++ b/test/chart/pie_chart/pie_chart_data_test.dart @@ -274,4 +274,46 @@ void main() { expect(mid.toRadius, 50); }); }); + + group('PieChartSectionData', () { + test('equality', () { + final a = + PieChartSectionData(value: 10, color: Colors.red, radialOffset: 8); + final b = + PieChartSectionData(value: 10, color: Colors.red, radialOffset: 8); + expect(a == b, true); + + expect( + a == PieChartSectionData(value: 10, color: Colors.red, radialOffset: 0), + false, + ); + }); + + test('copyWith', () { + final original = PieChartSectionData(value: 10, color: Colors.red); + expect(original.radialOffset, 0); + + final withOffset = original.copyWith(radialOffset: 12); + expect(withOffset.radialOffset, 12); + expect(withOffset.value, 10); + + expect(original.copyWith() == original, true); + }); + + test('lerp', () { + final a = + PieChartSectionData(value: 10, color: Colors.red, radialOffset: 0); + final b = + PieChartSectionData(value: 10, color: Colors.red, radialOffset: 20); + + final atZero = PieChartSectionData.lerp(a, b, 0); + expect(atZero.radialOffset, 0); + + final atOne = PieChartSectionData.lerp(a, b, 1); + expect(atOne.radialOffset, 20); + + final mid = PieChartSectionData.lerp(a, b, 0.5); + expect(mid.radialOffset, 10); + }); + }); } diff --git a/test/chart/pie_chart/pie_chart_painter_test.dart b/test/chart/pie_chart/pie_chart_painter_test.dart index c792d3443..78cd31bbe 100644 --- a/test/chart/pie_chart/pie_chart_painter_test.dart +++ b/test/chart/pie_chart/pie_chart_painter_test.dart @@ -369,6 +369,69 @@ void main() { ); expect(results[3]['paint_style'] as PaintingStyle, PaintingStyle.fill); }); + + test('test 3 - radialOffset translates section along center angle', () { + const viewSize = Size(200, 200); + const centerRadius = 10.0; + const offset = 20.0; + + final section0 = PieChartSectionData( + color: MockData.color1, + value: 1, + radialOffset: offset, + ); + final section1 = PieChartSectionData( + color: MockData.color2, + value: 1, + ); + final data = PieChartData( + sectionsSpace: 0, + sections: [section0, section1], + ); + + final pieChartPainter = PieChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + final drawnPaths = []; + when(mockCanvasWrapper.drawPath(captureAny, captureAny)).thenAnswer( + (inv) => drawnPaths.add(inv.positionalArguments[0] as Path), + ); + + pieChartPainter.drawSections( + mockCanvasWrapper, + [180, 180], + centerRadius, + holder, + ); + + expect(drawnPaths.length, 2); + + const section0Center = Offset(100, 120); + final expectedPath0 = pieChartPainter.generateSectionPath( + section0, + 0, + 0, + 180, + section0Center, + centerRadius, + ); + expect(HelperMethods.equalsPaths(drawnPaths[0], expectedPath0), true); + + const section1Center = Offset(100, 100); + final expectedPath1 = pieChartPainter.generateSectionPath( + section1, + 0, + 180, + 180, + section1Center, + centerRadius, + ); + expect(HelperMethods.equalsPaths(drawnPaths[1], expectedPath1), true); + }); }); group('generateSectionPath()', () {