diff --git a/example/lib/main.dart b/example/lib/main.dart index a021e21bb..371927f9b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,10 +1,8 @@ -import 'package:fl_chart_app/cubits/app/app_cubit.dart'; -import 'package:fl_chart_app/presentation/resources/app_resources.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'dart:math'; -import 'presentation/router/app_router.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:vector_math/vector_math.dart'; void main() { runApp(const MyApp()); @@ -15,23 +13,32 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider(create: (BuildContext context) => AppCubit()), - ], - child: MaterialApp.router( - title: AppTexts.appName, - theme: ThemeData( - brightness: Brightness.dark, - useMaterial3: true, - textTheme: GoogleFonts.assistantTextTheme( - Theme.of(context).textTheme.apply( - bodyColor: AppColors.mainTextColor3, + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + height: 300, + child: LineChart( + LineChartData( + lineBarsData: [ + LineChartBarData( + spots: List.generate( + 360 * 1, + (index) => FlSpot( + index.toDouble(), + sin(radians(index.toDouble())), + ), + ), + ), + ], + horizontalZoomConfig: const ZoomConfig( + enabled: true, + amount: 20, ), + ), + ), ), - scaffoldBackgroundColor: AppColors.pageBackground, ), - routerConfig: appRouterConfig, ), ); } diff --git a/lib/src/chart/base/axis_chart/axis_chart_data.dart b/lib/src/chart/base/axis_chart/axis_chart_data.dart index 156d98df1..91e4e65fe 100644 --- a/lib/src/chart/base/axis_chart/axis_chart_data.dart +++ b/lib/src/chart/base/axis_chart/axis_chart_data.dart @@ -29,6 +29,7 @@ abstract class AxisChartData extends BaseChartData with EquatableMixin { super.borderData, required super.touchData, ExtraLinesData? extraLinesData, + this.horizontalZoomConfig = const ZoomConfig(), }) : gridData = gridData ?? const FlGridData(), rangeAnnotations = rangeAnnotations ?? const RangeAnnotations(), baselineX = baselineX ?? 0, @@ -62,6 +63,8 @@ abstract class AxisChartData extends BaseChartData with EquatableMixin { /// Extra horizontal or vertical lines to draw on the chart. final ExtraLinesData extraLinesData; + final ZoomConfig horizontalZoomConfig; + /// Used for equality check, see [EquatableMixin]. @override List get props => [ @@ -79,6 +82,7 @@ abstract class AxisChartData extends BaseChartData with EquatableMixin { borderData, touchData, extraLinesData, + horizontalZoomConfig, ]; } @@ -1639,3 +1643,19 @@ class FlDotCrossPainter extends FlDotPainter { width, ]; } + +class ZoomConfig with EquatableMixin { + const ZoomConfig({ + this.enabled = false, + this.amount = 10, + }); + + final bool enabled; + final double amount; + + @override + List get props => [ + enabled, + amount, + ]; +} diff --git a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart index d67421450..2bc7fbe81 100644 --- a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart +++ b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart @@ -19,70 +19,123 @@ import 'package:flutter/material.dart'; /// `left`, `top`, `right`, `bottom` are some place holders to show titles /// provided by [AxisChartData.titlesData] around the chart /// `chart` is a centered place holder to show a raw chart. -class AxisChartScaffoldWidget extends StatelessWidget { +class AxisChartScaffoldWidget extends StatefulWidget { const AxisChartScaffoldWidget({ super.key, required this.chart, required this.data, }); + final Widget chart; final AxisChartData data; + @override + State createState() => + _AxisChartScaffoldWidgetState(); +} + +class _AxisChartScaffoldWidgetState extends State { + late ScrollController scrollController; + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + bool get showLeftTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.leftTitles.showAxisTitles; - final showSideTitles = data.titlesData.leftTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.leftTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.leftTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showRightTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.rightTitles.showAxisTitles; - final showSideTitles = data.titlesData.rightTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.rightTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.rightTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showTopTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.topTitles.showAxisTitles; - final showSideTitles = data.titlesData.topTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.topTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.topTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showBottomTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.bottomTitles.showAxisTitles; - final showSideTitles = data.titlesData.bottomTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.bottomTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.bottomTitles.showSideTitles; return showAxisTitles || showSideTitles; } List stackWidgets(BoxConstraints constraints) { + final chartWidth = constraints.maxWidth - + widget.data.titlesData.allSidesPadding.horizontal; + + final xDelta = widget.data.maxX - widget.data.minX; + final largeChartWidth = xDelta * widget.data.horizontalZoomConfig.amount; + final widgets = [ Container( - margin: data.titlesData.allSidesPadding, + margin: widget.data.titlesData.allSidesPadding, decoration: BoxDecoration( - border: data.borderData.isVisible() ? data.borderData.border : null, + border: widget.data.borderData.isVisible() + ? widget.data.borderData.border + : null, ), - child: chart, + child: switch (widget.data.horizontalZoomConfig.enabled) { + true => SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: largeChartWidth, + height: constraints.maxHeight, + child: widget.chart, + ), + ), + false => SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: widget.chart, + ), + }, ), ]; int insertIndex(bool drawBelow) => drawBelow ? 0 : widgets.length; + double? axisMinXOverride; + double? axisMaxXOverride; + if (scrollController.hasClients) { + final xAmount = widget.data.horizontalZoomConfig.amount; + final showingXDelta = chartWidth / xAmount; + axisMinXOverride = scrollController.offset / xAmount; + axisMaxXOverride = axisMinXOverride + showingXDelta; + } + if (showLeftTitles) { widgets.insert( - insertIndex(data.titlesData.leftTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.leftTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.left, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, ), ); @@ -90,21 +143,23 @@ class AxisChartScaffoldWidget extends StatelessWidget { if (showTopTitles) { widgets.insert( - insertIndex(data.titlesData.topTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.topTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.top, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + axisMinOverride: axisMinXOverride, + axisMaxOverride: axisMaxXOverride, ), ); } if (showRightTitles) { widgets.insert( - insertIndex(data.titlesData.rightTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.rightTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.right, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, ), ); @@ -112,11 +167,13 @@ class AxisChartScaffoldWidget extends StatelessWidget { if (showBottomTitles) { widgets.insert( - insertIndex(data.titlesData.bottomTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.bottomTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.bottom, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + axisMinOverride: axisMinXOverride, + axisMaxOverride: axisMaxXOverride, ), ); } @@ -125,9 +182,16 @@ class AxisChartScaffoldWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return Stack(children: stackWidgets(constraints)); + return ListenableBuilder( + listenable: scrollController, + builder: (context, child) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: stackWidgets(constraints), + ); + }, + ); }, ); } diff --git a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart index c1363b39a..359dd40d9 100644 --- a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart +++ b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart @@ -14,18 +14,23 @@ class SideTitlesWidget extends StatelessWidget { required this.side, required this.axisChartData, required this.parentSize, + this.axisMinOverride, + this.axisMaxOverride, }); + final AxisSide side; final AxisChartData axisChartData; final Size parentSize; + final double? axisMinOverride; + final double? axisMaxOverride; bool get isHorizontal => side == AxisSide.top || side == AxisSide.bottom; bool get isVertical => !isHorizontal; - double get minX => axisChartData.minX; + double get minX => axisMinOverride ?? axisChartData.minX; - double get maxX => axisChartData.maxX; + double get maxX => axisMaxOverride ?? axisChartData.maxX; double get baselineX => axisChartData.baselineX; @@ -110,6 +115,7 @@ class SideTitlesWidget extends StatelessWidget { double axisMin, double axisMax, AxisSide side, + ZoomConfig xAxisZoom, ) { List axisPositions; final interval = sideTitles.interval ?? @@ -153,9 +159,17 @@ class SideTitlesWidget extends StatelessWidget { } return axisPositions.map( (metaData) { - return AxisSideTitleWidgetHolder( - metaData, - sideTitles.getTitlesWidget( + final Widget widget; + final isOutOfHorizontalAxis = isHorizontal && + (metaData.axisValue < axisChartData.minX || + metaData.axisValue > axisChartData.maxX); + final isOutOfVerticalAxis = isVertical && + (metaData.axisValue < axisChartData.minY || + metaData.axisValue > axisChartData.maxY); + if (isOutOfHorizontalAxis || isOutOfVerticalAxis) { + widget = Container(); + } else { + widget = sideTitles.getTitlesWidget( metaData.axisValue, TitleMeta( min: axisMin, @@ -171,8 +185,9 @@ class SideTitlesWidget extends StatelessWidget { parentAxisSize: axisViewSize, axisPosition: metaData.axisPixelLocation, ), - ), - ); + ); + } + return AxisSideTitleWidgetHolder(metaData, widget); }, ).toList(); } @@ -197,6 +212,7 @@ class SideTitlesWidget extends StatelessWidget { ), if (sideTitles.showTitles) Container( + color: Colors.red, width: isHorizontal ? axisViewSize : sideTitles.reservedSize, height: isHorizontal ? sideTitles.reservedSize : axisViewSize, margin: thisSidePadding, @@ -212,6 +228,7 @@ class SideTitlesWidget extends StatelessWidget { axisMin, axisMax, side, + axisChartData.horizontalZoomConfig, ), ), ), @@ -233,6 +250,7 @@ class _AxisTitleWidget extends StatelessWidget { required this.side, required this.axisViewSize, }); + final AxisTitles axisTitles; final AxisSide side; final double axisViewSize; diff --git a/lib/src/chart/line_chart/line_chart_data.dart b/lib/src/chart/line_chart/line_chart_data.dart index 02ffb9eb7..ec0a342db 100644 --- a/lib/src/chart/line_chart/line_chart_data.dart +++ b/lib/src/chart/line_chart/line_chart_data.dart @@ -58,6 +58,7 @@ class LineChartData extends AxisChartData with EquatableMixin { super.baselineY, super.clipData = const FlClipData.none(), super.backgroundColor, + super.horizontalZoomConfig, }) : super( touchData: lineTouchData, minX: minX ?? double.nan, @@ -135,6 +136,7 @@ class LineChartData extends AxisChartData with EquatableMixin { double? baselineY, FlClipData? clipData, Color? backgroundColor, + ZoomConfig? horizontalZoomConfig, }) { return LineChartData( lineBarsData: lineBarsData ?? this.lineBarsData, @@ -155,6 +157,7 @@ class LineChartData extends AxisChartData with EquatableMixin { baselineY: baselineY ?? this.baselineY, clipData: clipData ?? this.clipData, backgroundColor: backgroundColor ?? this.backgroundColor, + horizontalZoomConfig: horizontalZoomConfig ?? this.horizontalZoomConfig, ); } @@ -178,6 +181,7 @@ class LineChartData extends AxisChartData with EquatableMixin { baselineY, clipData, backgroundColor, + horizontalZoomConfig, ]; } diff --git a/test/chart/bar_chart/bar_chart_alignment_widget_test.dart b/test/chart/bar_chart/bar_chart_alignment_widget_test.dart new file mode 100644 index 000000000..be90880fc --- /dev/null +++ b/test/chart/bar_chart/bar_chart_alignment_widget_test.dart @@ -0,0 +1,79 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List get barGroups => [ + BarChartGroupData(x: 0, barRods: [BarChartRodData(toY: 8)]), + BarChartGroupData(x: 1, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 2, barRods: [BarChartRodData(toY: 14)]), + BarChartGroupData(x: 3, barRods: [BarChartRodData(toY: 15)]), + BarChartGroupData(x: 4, barRods: [BarChartRodData(toY: 13)]), + BarChartGroupData(x: 5, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 6, barRods: [BarChartRodData(toY: 16)]), + BarChartGroupData(x: 7, barRods: [BarChartRodData(toY: 8)]), + BarChartGroupData(x: 8, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 9, barRods: [BarChartRodData(toY: 14)]), + BarChartGroupData(x: 10, barRods: [BarChartRodData(toY: 15)]), + BarChartGroupData(x: 11, barRods: [BarChartRodData(toY: 13)]), + BarChartGroupData(x: 12, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 13, barRods: [BarChartRodData(toY: 16)]), + BarChartGroupData(x: 14, barRods: [BarChartRodData(toY: 8)]), + BarChartGroupData(x: 15, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 16, barRods: [BarChartRodData(toY: 14)]), + BarChartGroupData(x: 17, barRods: [BarChartRodData(toY: 15)]), + BarChartGroupData(x: 18, barRods: [BarChartRodData(toY: 13)]), + BarChartGroupData(x: 19, barRods: [BarChartRodData(toY: 10)]), + BarChartGroupData(x: 20, barRods: [BarChartRodData(toY: 16)]), + ]; + +void main() { + const viewSize = Size(400, 400); + + testWidgets( + 'Barchart alignment overflow test', + (WidgetTester tester) async { + // Test that the bar chart alignment works as expected when the + // bar groups are too wide to fit in the chart. + for (final groupsSpace in [4.0, 20.0]) { + for (final barChartAlignment in [ + BarChartAlignment.start, + BarChartAlignment.center, + BarChartAlignment.end, + ]) { + // Build the bar chart. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: BarChart( + BarChartData( + barGroups: barGroups, + gridData: const FlGridData(show: false), + alignment: barChartAlignment, + groupsSpace: groupsSpace, + maxY: 20, + ), + ), + ), + ), + ), + ), + ); + + // Wait for the chart to be rendered. + await tester.pumpAndSettle(); + + // Take a golden image. + final fname = '${barChartAlignment}_$groupsSpace'; + await expectLater( + find.byType(BarChart), + matchesGoldenFile('golden/$fname.png'), + ); + } + } + }, + ); +}