diff --git a/.gitignore b/.gitignore index e9b2ec3..7859d28 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,8 @@ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# VSCode related +.vscode/ ### Flutter ### # Flutter/Dart/Pub related diff --git a/README.md b/README.md index efd8bfe..011fd26 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ barItemBuilder: (data) { The `data` that’s passed into the builder can be used to build different kind of item based on the item value (`data.item.value`), his index in data (`data.itemIndex`) or based on which data list it belongs to (`data.listIndex`). -Besides builder, the other useful parameters in item options are `maxBarWidth` , `minBarWidth` , `startPosition` and `padding`. +Besides builder, the other useful parameters in item options are `maxBarWidth` , `minBarWidth` , `startPosition` , `padding` and `widthCalculator`.
+`widthCalculator` is used in scrollable charts when `visibleItems` is not `null` and it provides a way to control the width of items. If you want to listen to **item taps** you can do it by setting `ChartBehaviour(onItemClicked)` - you can read more about ChartBehaviour below. In case of a WidgetItemOptions, you could also provide GestureDetectors and Buttons and they will all work. @@ -106,8 +107,8 @@ In case of a WidgetItemOptions, you could also provide GestureDetectors and Butt Decorations enhance and complete the look of the chart. Everything that’s drawn on a chart, and it’s not a chart item is considered a decoration. So that means a lot of the chart will be a decoration. Just like with the items, you can use **WidgetDecoration** to draw any kind of the decoration, but the most common cases for decoration are already made on a canvas and ready to be used: | | | | -:------: | :------: | :------: -[![horizontal_decoration] Horizontal decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#horizontal-decoration-golden) | [![vertical_decoration] Vertical decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#vertical-decoration-golden) | [![grid_decoration] Grid decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#grid-decoration-golden) +:------: | :------: | :------: +[![horizontal_decoration] Horizontal decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#horizontal-decoration-golden) | [![vertical_decoration] Vertical decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#vertical-decoration-golden) | [![grid_decoration] Grid decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#grid-decoration-golden) [![sparkline_decoration] Sparkline decoration](https://github.com/infinum/flutter-charts/blob/master/test/golden/GOLDENS.md#sparkline-decoration-golden) | @@ -139,9 +140,10 @@ You can add padding that equals the chart margins which will set you to the star ## Chart behaviour -Chart behaviour has just two parameters: +Chart behaviour has just three parameters: - `isScrollable` - will render a chart that can support scrolling. You still need to wrap it with SingleChildScrollView. +- `visibleItems` - used when `isScrollable` is set to `true`. Indicates how many items should visible on the screen - `onItemClicked` - when set the tap events on items are registered and will invoke this method. If you're using WidgetItemOptions, you could set a gesture detector there, but this works with both BarItemOptions, BubbleItemOptions and WidgetItemOptions. diff --git a/analysis_options.yaml b/analysis_options.yaml index 043a519..9198f69 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -41,4 +41,5 @@ analyzer: exclude: - "bin/cache/**" - "lib/assets.dart" -# - "example/**" \ No newline at end of file +# - "example/**" +# - "charts_web/**" diff --git a/example/lib/charts/scrollable_chart_screen.dart b/example/lib/charts/scrollable_chart_screen.dart index b6f058e..2913567 100644 --- a/example/lib/charts/scrollable_chart_screen.dart +++ b/example/lib/charts/scrollable_chart_screen.dart @@ -90,7 +90,8 @@ class _ScrollableChartScreenState extends State { }, ), behaviour: ChartBehaviour( - isScrollable: _isScrollable, + scrollSettings: + _isScrollable ? ScrollSettings() : ScrollSettings.none(), onItemClicked: (item) { setState(() { _selected = item.itemIndex; diff --git a/example/lib/charts/scrollable_visible_items_chart_screen.dart b/example/lib/charts/scrollable_visible_items_chart_screen.dart new file mode 100644 index 0000000..0c09d18 --- /dev/null +++ b/example/lib/charts/scrollable_visible_items_chart_screen.dart @@ -0,0 +1,216 @@ +import 'dart:math'; + +import 'package:charts_painter/chart.dart'; +import 'package:example/widgets/candle_chart.dart'; +import 'package:example/widgets/chart_options.dart'; +import 'package:example/widgets/counter_item.dart'; +import 'package:example/widgets/toggle_item.dart'; +import 'package:flutter/material.dart'; + +class CandleItem { + CandleItem(this.min, this.max); + + final double max; + final double min; +} + +class ScrollableVisibleItemsChartScreen extends StatefulWidget { + ScrollableVisibleItemsChartScreen({Key? key}) : super(key: key); + + @override + _ScrollableVisibleItemsChartScreenState createState() => + _ScrollableVisibleItemsChartScreenState(); +} + +class _ScrollableVisibleItemsChartScreenState + extends State { + List _values = []; + double targetMax = 0; + double targetMin = 0; + + bool _showValues = false; + int minItems = 25; + int? _selected; + bool _isScrollable = true; + int _visibleItems = 6; + + @override + void initState() { + super.initState(); + _updateValues(); + } + + void _updateValues() { + final Random _rand = Random(); + final double _difference = _rand.nextDouble() * 15; + final double _max = 8 + _difference; + + targetMax = _max * 0.75 + (_rand.nextDouble() * _max * 0.25); + targetMin = targetMax - (3 + (_rand.nextDouble() * (_max / 2))); + _values.addAll(List.generate(minItems, (index) { + double _value = 2 + _rand.nextDouble() * _difference; + return CandleItem(_value, _value + 2 + _rand.nextDouble() * 4); + })); + } + + void _addValues() { + _values = List.generate(minItems, (index) { + if (_values.length > index) { + return _values[index]; + } + double _value = 2 + Random().nextDouble() * targetMax; + return CandleItem(_value, _value + 2 + Random().nextDouble() * 4); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + 'Candle chart', + ), + ), + body: Column( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: _isScrollable + ? ScrollPhysics() + : NeverScrollableScrollPhysics(), + child: CandleChart( + data: _values, + height: MediaQuery.of(context).size.height * 0.4, + width: MediaQuery.of(context).size.width - 48, + dataToValue: (CandleItem value) => + CandleValue(value.min, value.max), + chartItemOptions: BarItemOptions( + minBarWidth: 10.0, + padding: EdgeInsets.symmetric(horizontal: 2.0), + barItemBuilder: (_) { + return BarItem( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(1.0), + radius: BorderRadius.all( + Radius.circular(100.0), + ), + ); + }, + ), + chartBehaviour: ChartBehaviour( + scrollSettings: _isScrollable + ? ScrollSettings(visibleItems: _visibleItems.toDouble()) + : ScrollSettings.none(), + onItemClicked: (item) { + setState(() { + _selected = item.itemIndex; + }); + }, + ), + backgroundDecorations: [ + GridDecoration( + showHorizontalValues: _showValues, + showVerticalGrid: true, + showVerticalValues: _showValues, + verticalValuesPadding: EdgeInsets.only(left: 8.0), + horizontalAxisStep: 5, + verticalTextAlign: TextAlign.start, + gridColor: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.2), + textStyle: Theme.of(context) + .textTheme + .caption! + .copyWith(fontSize: 13.0), + ), + ], + foregroundDecorations: [ + ValueDecoration( + textStyle: TextStyle(color: Colors.red), + alignment: Alignment.topCenter, + ), + ValueDecoration( + textStyle: TextStyle(color: Colors.red), + alignment: Alignment.bottomCenter, + valueGenerator: (item) => item.min ?? 0, + ), + SelectedItemDecoration( + _selected, + backgroundColor: Theme.of(context) + .scaffoldBackgroundColor + .withOpacity(0.5), + ), + ], + ), + ), + ), + ), + Flexible( + child: ChartOptionsWidget( + onRefresh: () { + setState(() { + _values.clear(); + _updateValues(); + }); + }, + onAddItems: () { + setState(() { + minItems += 4; + _addValues(); + }); + }, + onRemoveItems: () { + setState(() { + if (_values.length > 4) { + minItems -= 4; + _values.removeRange(_values.length - 4, _values.length); + } + }); + }, + toggleItems: [ + ToggleItem( + title: 'Axis values', + value: _showValues, + onChanged: (value) { + setState(() { + _showValues = value; + }); + }, + ), + ToggleItem( + value: _isScrollable, + title: 'Scrollable', + onChanged: (value) { + setState(() { + _isScrollable = value; + }); + }, + ), + CounterItem( + title: 'Visible items', + value: _visibleItems, + onMinus: () { + setState(() { + _visibleItems = max(1, _visibleItems - 1); + }); + }, + onPlus: () { + setState(() { + _visibleItems = min(36, _visibleItems + 1); + }); + }, + ) + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 061a88c..fe5c1ea 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,6 +2,7 @@ import 'package:charts_painter/chart.dart'; import 'package:example/chart_types.dart'; import 'package:example/charts/bar_target_chart_screen.dart'; import 'package:example/charts/migration_chart_screen.dart'; +import 'package:example/charts/scrollable_visible_items_chart_screen.dart'; import 'package:example/complex/complex_charts.dart'; import 'package:example/showcase/ios_charts.dart'; import 'package:example/showcase/showcase_charts.dart'; @@ -228,6 +229,47 @@ class ShowList extends StatelessWidget { }, ), Divider(), + ListTile( + title: Text('Scrollable with visible items'), + trailing: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Container( + width: 100.0, + child: Chart( + state: ChartState( + data: ChartData.fromList( + [1, 3, 4, 2, 7, 6, 2, 5, 4, 2, 9, 10, 2, 4, 8, 7, 7, 6, 1] + .map((e) => + CandleValue(e.toDouble() + 6, e.toDouble())) + .toList(), + axisMax: 15, + ), + itemOptions: BarItemOptions( + barItemBuilder: (_) { + return BarItem( + radius: BorderRadius.all(Radius.circular(12.0)), + color: Theme.of(context).colorScheme.secondary, + ); + }, + padding: const EdgeInsets.symmetric(horizontal: 2.0), + ), + backgroundDecorations: [ + GridDecoration( + verticalAxisStep: 1, + horizontalAxisStep: 3, + gridColor: Theme.of(context).dividerColor, + ), + ], + ), + ), + ), + ), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ScrollableVisibleItemsChartScreen())); + }, + ), + Divider(), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Text( diff --git a/example/lib/widgets/candle_chart.dart b/example/lib/widgets/candle_chart.dart index 7352a53..0b14527 100644 --- a/example/lib/widgets/candle_chart.dart +++ b/example/lib/widgets/candle_chart.dart @@ -11,6 +11,7 @@ class CandleChart extends StatelessWidget { required this.data, required this.dataToValue, this.height = 240.0, + this.width, this.backgroundDecorations = const [], this.chartBehaviour = const ChartBehaviour(), this.chartItemOptions = const BarItemOptions(), @@ -24,6 +25,7 @@ class CandleChart extends StatelessWidget { final DataToValue dataToValue; final double height; + final double? width; final List backgroundDecorations; final List foregroundDecorations; final ChartBehaviour chartBehaviour; @@ -36,6 +38,7 @@ class CandleChart extends StatelessWidget { Widget build(BuildContext context) { return AnimatedChart( height: height, + width: width, duration: const Duration(milliseconds: 450), state: ChartState( data: ChartData(_mappedValues), diff --git a/example/lib/widgets/counter_item.dart b/example/lib/widgets/counter_item.dart new file mode 100644 index 0000000..9fbda6a --- /dev/null +++ b/example/lib/widgets/counter_item.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class CounterItem extends StatelessWidget { + CounterItem( + {required this.title, + required this.value, + required this.onMinus, + required this.onPlus, + Key? key}) + : super(key: key); + + final int value; + final String title; + final VoidCallback onMinus; + final VoidCallback onPlus; + + @override + Widget build(BuildContext context) { + return Container( + child: ListTile( + title: Text(title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onMinus, + child: AspectRatio( + aspectRatio: 1, + child: Icon(Icons.remove), + ), + ), + InkWell( + onTap: onPlus, + child: AspectRatio( + aspectRatio: 1, + child: Icon(Icons.add), + ), + ), + AspectRatio( + aspectRatio: 1, + child: Center(child: Text('$value')), + ), + ], + ), + )); + } +} diff --git a/lib/chart.dart b/lib/chart.dart index dfdb4e0..9dc873d 100644 --- a/lib/chart.dart +++ b/lib/chart.dart @@ -27,6 +27,7 @@ part 'chart/model/geometry/chart_item.dart'; /// Theme part 'chart/model/theme/chart_behaviour.dart'; +part 'chart/model/theme/scroll_settings.dart'; part 'chart/model/theme/item_theme/bar/bar_item_options.dart'; part 'chart/model/theme/item_theme/item_options.dart'; part 'chart/model/theme/item_theme/bar/bar_item.dart'; diff --git a/lib/chart/model/theme/chart_behaviour.dart b/lib/chart/model/theme/chart_behaviour.dart index 22b480e..e6f9499 100644 --- a/lib/chart/model/theme/chart_behaviour.dart +++ b/lib/chart/model/theme/chart_behaviour.dart @@ -4,37 +4,30 @@ part of charts_painter; /// [isScrollable] - If chart is scrollable then width of canvas is ignored and /// chart will take any size it needs. Chart has to be wrapped with [SingleChildScrollView] /// or similar scrollable widget. -/// [multiValueStack] - Defaults to true, Dictates how items in stack will be shown, if set to true items will stack on -/// each other, on false they will be side by side. /// [onItemClicked] - Returns index of clicked item. class ChartBehaviour { /// Default constructor for ChartBehaviour /// If chart is scrollable then it will ignore width limit and it should be wrapped in [SingleChildScrollView] const ChartBehaviour({ - bool isScrollable = false, + this.scrollSettings = const ScrollSettings.none(), this.onItemClicked, - }) : _isScrollable = isScrollable ? 1.0 : 0.0; + }); - ChartBehaviour._lerp(this._isScrollable, this.onItemClicked); + ChartBehaviour._lerp(this.scrollSettings, this.onItemClicked); - final double _isScrollable; + final ScrollSettings scrollSettings; /// Return index of item clicked. Since graph can be multi value, user /// will have to handle clicked index to show data they want to show final ValueChanged? onItemClicked; /// Return true if chart is currently scrollable - bool get isScrollable => _isScrollable > 0.5; + bool get isScrollable => scrollSettings._isScrollable > 0.5; /// Animate Behaviour from one state to other static ChartBehaviour lerp(ChartBehaviour a, ChartBehaviour b, double t) { - // This values should never return null, this is for null-safety - // But if it somehow does occur, then revert to default values - final _scrollableLerp = - lerpDouble(a._isScrollable, b._isScrollable, t) ?? 0.0; - return ChartBehaviour._lerp( - _scrollableLerp, + ScrollSettings.lerp(a.scrollSettings, b.scrollSettings, t), t > 0.5 ? b.onItemClicked : a.onItemClicked, ); } diff --git a/lib/chart/model/theme/item_theme/item_options.dart b/lib/chart/model/theme/item_theme/item_options.dart index d206d09..14ad1cb 100644 --- a/lib/chart/model/theme/item_theme/item_options.dart +++ b/lib/chart/model/theme/item_theme/item_options.dart @@ -29,7 +29,9 @@ abstract class ItemOptions { this.minBarWidth, this.startPosition = 0.5, required this.itemBuilder, - }); + }) : assert(maxBarWidth == null || + minBarWidth == null || + maxBarWidth >= minBarWidth); const ItemOptions._lerp({ required this.geometryPainter, @@ -53,8 +55,6 @@ abstract class ItemOptions { final ItemBuilder itemBuilder; - /// Define color for value, this allows different colors for different values - /// Max width of item in the chart final double? maxBarWidth; diff --git a/lib/chart/model/theme/item_theme/widget/widget_item_options.dart b/lib/chart/model/theme/item_theme/widget/widget_item_options.dart index 2bf2a56..cec1b2e 100644 --- a/lib/chart/model/theme/item_theme/widget/widget_item_options.dart +++ b/lib/chart/model/theme/item_theme/widget/widget_item_options.dart @@ -64,11 +64,12 @@ class WidgetItemOptions extends ItemOptions { @override ItemOptions animateTo(ItemOptions endValue, double t) { return WidgetItemOptions._lerp( - widgetItemBuilder: endValue is WidgetItemOptions - ? endValue.widgetItemBuilder - : widgetItemBuilder, - multiValuePadding: - EdgeInsets.lerp(multiValuePadding, endValue.multiValuePadding, t) ?? - EdgeInsets.zero); + widgetItemBuilder: endValue is WidgetItemOptions + ? endValue.widgetItemBuilder + : widgetItemBuilder, + multiValuePadding: + EdgeInsets.lerp(multiValuePadding, endValue.multiValuePadding, t) ?? + EdgeInsets.zero, + ); } } diff --git a/lib/chart/model/theme/scroll_settings.dart b/lib/chart/model/theme/scroll_settings.dart new file mode 100644 index 0000000..1cf47bf --- /dev/null +++ b/lib/chart/model/theme/scroll_settings.dart @@ -0,0 +1,30 @@ +part of charts_painter; + +class ScrollSettings { + const ScrollSettings({ + this.visibleItems, + }) : assert(visibleItems == null || visibleItems > 0, + 'visibleItems must be greater than 0'), + _isScrollable = 1.0; + + const ScrollSettings.none() + : _isScrollable = 0.0, + visibleItems = null; + + ScrollSettings._lerp(this._isScrollable, this.visibleItems); + + final double _isScrollable; + + /// Number of visible items on the screen. + final double? visibleItems; + + static ScrollSettings lerp(ScrollSettings a, ScrollSettings b, double t) { + // This values should never return null, this is for null-safety + // But if it somehow does occur, then revert to default values + final scrollableLerp = + lerpDouble(a._isScrollable, b._isScrollable, t) ?? 0.0; + final visibleLerp = lerpDouble(a.visibleItems, b.visibleItems, t); + + return ScrollSettings._lerp(scrollableLerp, visibleLerp); + } +} diff --git a/lib/chart/widgets/chart_widget.dart b/lib/chart/widgets/chart_widget.dart index 45e1443..c6da7fc 100644 --- a/lib/chart/widgets/chart_widget.dart +++ b/lib/chart/widgets/chart_widget.dart @@ -16,40 +16,78 @@ class _ChartWidget extends StatelessWidget { final double? width; final ChartState state; + double get _horizontalItemPadding => state.itemOptions.padding.horizontal; + + double _clampItemWidth(double width) { + final minBarWidth = state.itemOptions.minBarWidth; + final maxBarWidth = state.itemOptions.maxBarWidth; + + if (minBarWidth != null) { + return max(minBarWidth, width); + } + + if (maxBarWidth != null) { + return min(maxBarWidth, width); + } + + return width; + } + + double _calcItemWidthNonScrollable() { + return max( + state.itemOptions.minBarWidth ?? 0.0, + state.itemOptions.maxBarWidth ?? 0.0, + ); + } + + double _calcItemWidthForScrollable(double frameWidth) { + final visibleItems = state.behaviour.scrollSettings.visibleItems; + if (visibleItems == null) { + return _calcItemWidthNonScrollable(); + } + + final availableWidth = frameWidth - state.defaultPadding.horizontal; + final width = availableWidth / visibleItems - _horizontalItemPadding; + + return _clampItemWidth(max(0, width)); + } + + double _calcItemWidth(double frameWidth) { + // Used for smooth transition between scrollable and non-scrollable chart + final sizeTween = Tween( + begin: _calcItemWidthNonScrollable(), + end: _calcItemWidthForScrollable(frameWidth), + ); + + return sizeTween.transform(state.behaviour.scrollSettings._isScrollable); + } + + Size _calcChartSize(double itemWidth, double frameWidth, double frameHeight) { + final listSize = state.data.listSize; + final totalItemWidth = itemWidth + _horizontalItemPadding; + final listWidth = totalItemWidth * listSize; + + final chartWidth = frameWidth + + (listWidth - frameWidth) * state.behaviour.scrollSettings._isScrollable; + final finalWidth = chartWidth + state.defaultPadding.horizontal; + + return Size(finalWidth, frameHeight); + } + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - // What size does the item want to be? - final _wantedItemWidth = state.data.items.foldIndexed(0.0, - (index, double prevValue, _) { - return max( - prevValue, - max(state.itemOptions.minBarWidth ?? 0.0, - state.itemOptions.maxBarWidth ?? 0.0)); - }); - - final _width = + final frameWidth = constraints.maxWidth.isFinite ? constraints.maxWidth : width!; - final _height = + final frameHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : height!; - final _listSize = state.data.listSize; - - final _horizontalPadding = state.data.items.foldIndexed(0.0, - (index, double prevValue, _) { - return max(prevValue, state.itemOptions.padding.horizontal); - }); - - final _size = Size( - _width + - (((_wantedItemWidth + _horizontalPadding) * _listSize) - - _width) * - state.behaviour._isScrollable, - _height); + final itemWidth = _calcItemWidth(frameWidth); + final size = _calcChartSize(itemWidth, frameWidth, frameHeight); return Container( - constraints: BoxConstraints.tight(_size), + constraints: BoxConstraints.tight(size), child: ChartRenderer(state), ); },