Open nimesh1997 opened 5 years ago
Hi, I'm so happy to see your result :) Unfortunately, currently we are not going to implement the scrollable feature, I'm so busy these days and also pull requests are welcome, we will implement it in the future. Cheers! Thanks.
I left it open, and people can thumb it up, then I will do it with high priority.
@nimesh1997
You can put it in a Container bigger than the screen and put that Container in a SingleChildScrollView with scrollDirection: Axis.horizontal
@ZantsuRocks Greate solution, Thank you :)
@ZantsuRocks solution is really useful, but this is still a good feature to be implemented in the future. In my case, along with the scrolling behavior I need a callback to mark each bar as "touched" when the chart is scrolled.
@nimesh1997 You can put it in a Container bigger than the screen and put that Container in a SingleChildScrollView with
scrollDirection: Axis.horizontal
This will get perfomance problem ,especially in web .
@nimesh1997 You can put it in a Container bigger than the screen and put that Container in a SingleChildScrollView with
scrollDirection: Axis.horizontal
This is not working if I have a Sliver App Bar and the graph is placed in SliverChildListDelegate. I tried to change the width of container but it still is constant.
If anyone wants to achieve panning and mousewheel zooming, following code might help.
I have tested the following in flutter for windows and browser. In windows it works well and satisfies my needs. In browser however the panning is not good because of DragupdateDetails.primaryDelta
comes in as 0
quite often and hence the panning is jittery.
Note this logic still has flaws like minX and maxX not being clamped to stop zooming etc. However I feel this is good start.
@imaNNeoFighT I am not sure if this is a performant way, but seems to achieve some results. Atleast in windows I didn't feel any jitter. 😄
Idea is as follows.
Listener
widget to listen to mouse scroll events.
GestureDetector
widget to detect horizontal drag event.clipData: FlClipData.all()
of the LineChartData
. without this the plot is rendered outside the widget.Following example achieves panning and zooming only in x-axis. However the logic can be extended to yaxis as well.
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class PlotData {
List<double> result;
double maxY;
double minY;
PlotData({
required this.result,
required this.maxY,
required this.minY,
});
}
class LinePlot extends StatefulWidget {
final PlotData plotData;
const LinePlot({
required this.plotData,
Key? key,
}) : super(key: key);
@override
_LinePlotState createState() => _LinePlotState();
}
class _LinePlotState extends State<LinePlot> {
late double minX;
late double maxX;
@override
void initState() {
super.initState();
minX = 0;
maxX = widget.plotData.result.length.toDouble();
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
setState(() {
if (signal.scrollDelta.dy.isNegative) {
minX += maxX * 0.05;
maxX -= maxX * 0.05;
} else {
minX -= maxX * 0.05;
maxX += maxX * 0.05;
}
});
}
},
child: GestureDetector(
onDoubleTap: () {
setState(() {
minX = 0;
maxX = widget.plotData.result.length.toDouble();
});
},
onHorizontalDragUpdate: (dragUpdDet) {
setState(() {
print(dragUpdDet.primaryDelta);
double primDelta = dragUpdDet.primaryDelta ?? 0.0;
if (primDelta != 0) {
if (primDelta.isNegative) {
minX += maxX * 0.005;
maxX += maxX * 0.005;
} else {
minX -= maxX * 0.005;
maxX -= maxX * 0.005;
}
}
});
},
child: LineChart(
LineChartData(
minX: minX,
maxX: maxX,
maxY: widget.plotData.maxY + widget.plotData.maxY * 0.1,
titlesData: FlTitlesData(
bottomTitles: SideTitles(
showTitles: true,
interval: widget.plotData.result.length / 10,
),
leftTitles: SideTitles(
showTitles: true,
margin: 5,
),
topTitles: SideTitles(
showTitles: false,
margin: 5,
),
),
gridData: FlGridData(
drawHorizontalLine: false,
),
clipData: FlClipData.all(),
lineBarsData: [
LineChartBarData(
barWidth: 1,
dotData: FlDotData(
show: false,
),
spots: widget.plotData.result
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(),
)
],
),
),
),
);
}
}
This seems to have a lot of thumbs up now, are there any plans to support it in the near future? Thanks!
Hi @jlubeck. You are right, it has a lot of thumbs up. I don't think we support it in the near future. Because I have a full-time job and I don't have any profit from this project (that's why I can't work a lot on this project) I work just like before, I will implement these features in my free time. BTW I can't promise any due time.
Also pull requests are welcome.
Totally understandable @imaNNeoFighT I wasn't trying to sound pushy or anything. I even sent a PR in the past and worked with you, just that this issue is a little over my head haha.
Looking forward to whenever there's time to do it!
Thanks, @jlubeck. Yes, I remember you. I'm happy that you are an active member of our library.
Sorry if my answer bothered you.
Not a bother at all my friend!
I've found a function that I think can help to make it works.
https://www.reddit.com/r/flutterhelp/comments/nidlf5/how_to_make_graph_fl_chart_scrollable/
Widget makeScrollable(Widget w, {double width = 2000.0, double height = 2000.0}) {
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: width,
height: height,
child: ListView(
children: [
SizedBox(
width: width,
height: height,
child: w,
)
],
)
)
)
);
}
I left it open, and people can thumb it up, then I will do it with high priority.
So @imaNNeoFighT Do you have any plans to provide the support related to this issue, Where user can zoom in/out the graph as well use can horizontally(x-axis) scroll the data too?
Hi @DhavalRKansara. I really like to fix this issue (Because I know it helps you and my objective is to help). But you know it takes a lot of time and I'm not able to spend my time to solve this issue right now (because I'm a full-time developer like many of you). But definitely, I will work on it If I find a little free time.
Even though I developing this package opensource and free, donation is a good thing for motivation. I set a goal to work 2 weeks full time. Check it out on buyMeACoffee
If anyone wants to achieve panning and mousewheel zooming, following code might help. I have tested the following in flutter for windows and browser. In windows it works well and satisfies my needs. In browser however the panning is not good because of
DragupdateDetails.primaryDelta
comes in as0
quite often and hence the panning is jittery.Note this logic still has flaws like minX and maxX not being clamped to stop zooming etc. However I feel this is good start.
@imaNNeoFighT I am not sure if this is a performant way, but seems to achieve some results. Atleast in windows I didn't feel any jitter. 😄
Idea is as follows.
Use a
Listener
widget to listen to mouse scroll events.
- Increment and decrement minx and maxx by a fixed percentage of maxX, depending on the scroll direction.
- Use a
GestureDetector
widget to detect horizontal drag event.
- decrement both minX and maxX by a percentage if panning to the left. that is if primary delta is negative.
- Increment both minX and maxX by a percentage if panning to the right. that is if primary delta is positive.
- Finally clip the plot to be within the bounds using the
clipData: FlClipData.all()
of theLineChartData
. without this the plot is rendered outside the widget.Following example achieves panning and zooming only in x-axis. However the logic can be extended to yaxis as well.
import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class PlotData { List<double> result; double maxY; double minY; PlotData({ required this.result, required this.maxY, required this.minY, }); } class LinePlot extends StatefulWidget { final PlotData plotData; const LinePlot({ required this.plotData, Key? key, }) : super(key: key); @override _LinePlotState createState() => _LinePlotState(); } class _LinePlotState extends State<LinePlot> { late double minX; late double maxX; @override void initState() { super.initState(); minX = 0; maxX = widget.plotData.result.length.toDouble(); } @override Widget build(BuildContext context) { return Listener( onPointerSignal: (signal) { if (signal is PointerScrollEvent) { setState(() { if (signal.scrollDelta.dy.isNegative) { minX += maxX * 0.05; maxX -= maxX * 0.05; } else { minX -= maxX * 0.05; maxX += maxX * 0.05; } }); } }, child: GestureDetector( onDoubleTap: () { setState(() { minX = 0; maxX = widget.plotData.result.length.toDouble(); }); }, onHorizontalDragUpdate: (dragUpdDet) { setState(() { print(dragUpdDet.primaryDelta); double primDelta = dragUpdDet.primaryDelta ?? 0.0; if (primDelta != 0) { if (primDelta.isNegative) { minX += maxX * 0.005; maxX += maxX * 0.005; } else { minX -= maxX * 0.005; maxX -= maxX * 0.005; } } }); }, child: LineChart( LineChartData( minX: minX, maxX: maxX, maxY: widget.plotData.maxY + widget.plotData.maxY * 0.1, titlesData: FlTitlesData( bottomTitles: SideTitles( showTitles: true, interval: widget.plotData.result.length / 10, ), leftTitles: SideTitles( showTitles: true, margin: 5, ), topTitles: SideTitles( showTitles: false, margin: 5, ), ), gridData: FlGridData( drawHorizontalLine: false, ), clipData: FlClipData.all(), lineBarsData: [ LineChartBarData( barWidth: 1, dotData: FlDotData( show: false, ), spots: widget.plotData.result .asMap() .entries .map((entry) => FlSpot(entry.key.toDouble(), entry.value)) .toList(), ) ], ), ), ), ); } }
How if use BarChartData
? Because don't have minX
and maxX
and only have minY
and maxY
. I already try with that but it's not horizontal scroll but vertical scroll.
Hi @DhavalRKansara. I really like to fix this issue (Because I know it helps you and my objective is to help). But you know it takes a lot of time and I'm not able to spend my time to solve this issue right now (because I'm a full-time developer like many of you). But definitely, I will work on it If I find a little free time.
Even though I developing this package opensource and free, donation is a good thing for motivation. I set a goal to work 2 weeks full time. Check it out on buyMeACoffee
Sure @imaNNeoFighT, No issues I taught the issues is open since 2 years and you have close a lots of issues under this... So apart from lots of other people are waiting for this release since so long...
We badly want this feature.
This worked for me
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
width: 1000,
padding: EdgeInsets.all(8),
child: flchart.BarChart(
...
),
)
)
@imaNNeoFighT any update on the issue. We are not able to scroll horizontal axis
This worked for me
SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( width: 1000, padding: EdgeInsets.all(8), child: flchart.BarChart( ... ), ) )
@SomeoneAndNoone Can you please, Share a full source code ? How to solve above issue?
@imaNNeoFighT In another issue you mentioned:
Actually, in our latest release, we introduced a ChartScaffold class to separate the chart and titles. Now it allows us to have the chart itself scrollable (with fixed titles).
Is this available now? Are there any examples we can see to implement this, as it's exactly what I'm looking for? (fixed titles)
@imaNNeoFighT In another issue you mentioned:
Actually, in our latest release, we introduced a ChartScaffold class to separate the chart and titles. Now it allows us to have the chart itself scrollable (with fixed titles).
Is this available now? Are there any examples we can see to implement this, as it's exactly what I'm looking for? (fixed titles)
Yes, it is available now. But there is no public API to use in your app. You should change the internal library codes to achieve what you want. (We have an AxisChartScaffoldWidget
which is private in our lib)
BTW my next big step is going to implement this feature internally.
Sorry, this is a little beyond my skills. Is it possible for someone to share example code of how I might use this to get fixed left titles for a line chart I have in my app? The graph scrolls on the x-axis.
To achieve horizontal scrolling for the LineChart, I am wrapping it in a container with a large width (in code below it's 2 x the number of data points ) and then wrapping that container in a SingleChildScrollView and then wrapping that in a constrained container with a fixed max width )....
Container( constraints: BoxConstraints( maxWidth : 400), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( width: widget.mainPoints.length * 2, child: LineChart( ....
Hey @imaNNeoFighT any update on the issue?
No hate or offence but for an app claiming - "highly customizable Flutter chart library", with no horizontal scrolling for extra data, is not cool. Respect for all the effort though.
This worked for me
SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( width: 1000, padding: EdgeInsets.all(8), child: flchart.BarChart( ... ), ) )
Can you do this without losing the axis on the side? I scroll right and the left axis disappears
This worked for me
SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( width: 1000, padding: EdgeInsets.all(8), child: flchart.BarChart( ... ), ) )
Can you do this without losing the axis on the side? I scroll right and the left axis disappears
Hi @GivDavidL - Yes, this is possible, as the chart axis and chart itself can now be separated. I created a row, displayed the axis, then the chart, and then another axis. I will paste the code for the axis I created here:
Container(
color: Colors.transparent,
width: 14.0,
height: widget.chartHeight,
child: DecorationsRenderer(
[
HorizontalAxisDecoration(
showTopValue: true,
axisStep: 3,
showValues: true,
legendFontStyle: Theme.of(context).textTheme.caption,
lineColor: Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.2),
),
],
// Must pass same state as your chart, this is used to calculate spacings and padding of decoration, to make sure it matches the chart.
_chartState,
),
),
need this feature too.
This worked for me
SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( width: 1000, padding: EdgeInsets.all(8), child: flchart.BarChart( ... ), ) )
Can you do this without losing the axis on the side? I scroll right and the left axis disappears
Hi @GivDavidL - Yes, this is possible, as the chart axis and chart itself can now be separated. I created a row, displayed the axis, then the chart, and then another axis. I will paste the code for the axis I created here:
Container( color: Colors.transparent, width: 14.0, height: widget.chartHeight, child: DecorationsRenderer( [ HorizontalAxisDecoration( showTopValue: true, axisStep: 3, showValues: true, legendFontStyle: Theme.of(context).textTheme.caption, lineColor: Theme.of(context) .colorScheme .primaryContainer .withOpacity(0.2), ), ], // Must pass same state as your chart, this is used to calculate spacings and padding of decoration, to make sure it matches the chart. _chartState, ), ),
Unless I'm mistaken, you're responding to an issue on the wrong plugin. This is fl_chart
not charts_painter
. fl_chart
doesn't have DecorationsRenderer
, ChartState
or HorizontalAxisDecoration
.
Unless I'm mistaken, you're responding to an issue on the wrong plugin. This is
fl_chart
notcharts_painter
.fl_chart
doesn't haveDecorationsRenderer
,ChartState
orHorizontalAxisDecoration
.
My apologies, you are correct. I haven't looked into this in a while and forgot I had changed so I could have axis that didnt disappear.
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
import 'dart:math';
import 'package:flutter/material.dart';
class ZoomableChart extends StatefulWidget {
ZoomableChart({
super.key,
required this.maxX,
required this.builder,
});
double maxX;
Widget Function(double, double) builder;
@override
State<ZoomableChart> createState() => _ZoomableChartState();
}
class _ZoomableChartState extends State<ZoomableChart> {
late double minX;
late double maxX;
late double lastMaxXValue;
late double lastMinXValue;
@override
void initState() {
super.initState();
minX = 0;
maxX = widget.maxX;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: () {
setState(() {
minX = 0;
maxX = widget.maxX;
});
},
onHorizontalDragStart: (details) {
lastMinXValue = minX;
lastMaxXValue = maxX;
},
onHorizontalDragUpdate: (details) {
var horizontalDistance = details.primaryDelta ?? 0;
if (horizontalDistance == 0) return;
print(horizontalDistance);
var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0.0);
setState(() {
minX -= lastMinMaxDistance * 0.005 * horizontalDistance;
maxX -= lastMinMaxDistance * 0.005 * horizontalDistance;
if (minX < 0) {
minX = 0;
maxX = lastMinMaxDistance;
}
if (maxX > widget.maxX) {
maxX = widget.maxX;
minX = maxX - lastMinMaxDistance;
}
print("$minX, $maxX");
});
},
onScaleStart: (details) {
lastMinXValue = minX;
lastMaxXValue = maxX;
},
onScaleUpdate: (details) {
var horizontalScale = details.horizontalScale;
if (horizontalScale == 0) return;
print(horizontalScale);
var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0);
var newMinMaxDistance = max(lastMinMaxDistance / horizontalScale, 10);
var distanceDifference = newMinMaxDistance - lastMinMaxDistance;
print("$lastMinMaxDistance, $newMinMaxDistance, $distanceDifference");
setState(() {
final newMinX = max(
lastMinXValue - distanceDifference,
0.0,
);
final newMaxX = min(
lastMaxXValue + distanceDifference,
widget.maxX,
);
if (newMaxX - newMinX > 2) {
minX = newMinX;
maxX = newMaxX;
}
print("$minX, $maxX");
});
},
child: widget.builder(minX, maxX),
);
}
}
Copy and paste that code in a file like zoomable_chart.dart
. Then wrap your chart with ZoomableChart, provide a maxX and a function that returns your widget.
Example
return AspectRatio(
aspectRatio: 16 / 9,
child: ZoomableChart(
maxX: /* maxX OF YOUR DATA HERE */,
builder: (minX, maxX) {
return LineChart(
LineChartData(
clipData: FlClipData.all(),
minX: minX,
maxX: maxX,
lineTouchData: LineTouchData(enabled: false),
lineBarsData: [
LineChartBarData(
spots: /* DATA HERE */,
isCurved: false,
barWidth: 2,
color: line2Color,
dotData: FlDotData(
show: false,
),
),
],
minY: 1,
borderData: FlBorderData(
show: false,
),
),
);
},
),
);
Working demo
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
... Screen.Recording.2023-02-02.at.16.36.18.mov
It is a performant solution. Because by putting it inside a ScrollView, it draws the whole chart but you see a portion of it. But in your solution, it just renders what you see.
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
import 'dart:math'; import 'package:flutter/material.dart'; class ZoomableChart extends StatefulWidget { ZoomableChart({ super.key, required this.maxX, required this.builder, }); double maxX; Widget Function(double, double) builder; @override State<ZoomableChart> createState() => _ZoomableChartState(); } class _ZoomableChartState extends State<ZoomableChart> { late double minX; late double maxX; late double lastMaxXValue; late double lastMinXValue; @override void initState() { super.initState(); minX = 0; maxX = widget.maxX; } @override Widget build(BuildContext context) { return GestureDetector( onDoubleTap: () { setState(() { minX = 0; maxX = widget.maxX; }); }, onHorizontalDragStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; }, onHorizontalDragUpdate: (details) { var horizontalDistance = details.primaryDelta ?? 0; if (horizontalDistance == 0) return; print(horizontalDistance); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0.0); setState(() { minX -= lastMinMaxDistance * 0.005 * horizontalDistance; maxX -= lastMinMaxDistance * 0.005 * horizontalDistance; if (minX < 0) { minX = 0; maxX = lastMinMaxDistance; } if (maxX > widget.maxX) { maxX = widget.maxX; minX = maxX - lastMinMaxDistance; } print("$minX, $maxX"); }); }, onScaleStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; }, onScaleUpdate: (details) { var horizontalScale = details.horizontalScale; if (horizontalScale == 0) return; print(horizontalScale); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0); var newMinMaxDistance = max(lastMinMaxDistance / horizontalScale, 10); var distanceDifference = newMinMaxDistance - lastMinMaxDistance; print("$lastMinMaxDistance, $newMinMaxDistance, $distanceDifference"); setState(() { final newMinX = max( lastMinXValue - distanceDifference, 0.0, ); final newMaxX = min( lastMaxXValue + distanceDifference, widget.maxX, ); if (newMaxX - newMinX > 2) { minX = newMinX; maxX = newMaxX; } print("$minX, $maxX"); }); }, child: widget.builder(minX, maxX), ); } }
Copy and paste that code in a file like
zoomable_chart.dart
. Then wrap your chart with ZoomableChart, provide a maxX and a function that returns your widget.Example
return AspectRatio( aspectRatio: 16 / 9, child: ZoomableChart( maxX: /* maxX OF YOUR DATA HERE */, builder: (minX, maxX) { return LineChart( LineChartData( clipData: FlClipData.all(), minX: minX, maxX: maxX, lineTouchData: LineTouchData(enabled: false), lineBarsData: [ LineChartBarData( spots: /* DATA HERE */, isCurved: false, barWidth: 2, color: line2Color, dotData: FlDotData( show: false, ), ), ], minY: 1, borderData: FlBorderData( show: false, ), ), ); }, ), );
Working demo
Screen.Recording.2023-02-02.at.16.36.18.mov
does this only work on mobile? do you have any samples for web?
@aguilanbon I've only tested it on mobile, but it should work on web too. Note that I removed the Listener
widget class that's present on the original solution from @Abhilash-Chandran . This widget listens to PointerScrollEvent
, so my solution does not respond to the scroll wheel. You can extend my solution, adding back that Listener and performing the same work done on onHorizontalDragUpdate
for scrolling through the data on the X axis
@TeoVogel can you post a full example? I'm trying use your solution, but i've some trouble. Thanks
I achieved a satisfactory result with little effort using a dynamic x-axis, this way I can change the range of the x-axis only with a gesture detection in the graph component.
@observable
int xMinValue = 0;
@action
void updateXMinValue(int newValue) {
xMinValue = newValue;
}
That way you can generate the bars in a specific range
for (int i = controller.xMinValue; i <= indexController; i++) {
rawBarGroups.add(makeGroupData(
x: i,
bar1: widget.chartDataSet[i].bar1));
}
Finally, you can change this range from a swipe movement in the graph component, one way to do this is using the GestureDetector.
GestureDetector(
onHorizontalDragEnd: (DragEndDetails details) {
if (details.primaryVelocity! > 0 && controller.xMinValue > 0) {
controller.updateXMinValue(controller.xMinValue - 1);
} else if (details.primaryVelocity! < 0 && widget.chartDataSet.first.index! > controller.xMinValue + 7) {
controller.updateXMinValue(controller.xMinValue + 1);
}
},
child: BarChart(
BarChartData( ...
In a graph with a large number of spots, this solution can become a usability problem as navigation tends to be a little slower by using the swipe gesture. In these cases I recommend using a solution closer to a zoom in the graph: https://github.com/imaNNeo/fl_chart/issues/71#issuecomment-1414267612
@GustavoDuregger
I achieved a satisfactory result with little effort using a dynamic x-axis, this way I can change the range of the x-axis only with a gesture detection in the graph component.
@observable int xMinValue = 0; @action void updateXMinValue(int newValue) { xMinValue = newValue; }
That way you can generate the bars in a specific range
for (int i = controller.xMinValue; i <= indexController; i++) { rawBarGroups.add(makeGroupData( x: i, bar1: widget.chartDataSet[i].bar1)); }
Finally, you can change this range from a swipe movement in the graph component, one way to do this is using the GestureDetector.
GestureDetector( onHorizontalDragEnd: (DragEndDetails details) { if (details.primaryVelocity! > 0 && controller.xMinValue > 0) { controller.updateXMinValue(controller.xMinValue - 1); } else if (details.primaryVelocity! < 0 && widget.chartDataSet.first.index! > controller.xMinValue + 7) { controller.updateXMinValue(controller.xMinValue + 1); } }, child: BarChart( BarChartData( ...
In a graph with a large number of spots, this solution can become a usability problem as navigation tends to be a little slower by using the swipe gesture. In these cases I recommend using a solution closer to a zoom in the graph: #71 (comment)
but no minX and maxX in BarChartData
Hello everyone, any update on this issue?
Hi, unfortunately, I couldn't find enough free time to do that yet. Please stay tuned, you can also be my sponsor to motivate me, and then I can put more time into this project.
I Wrapped my Barchart inside a SizedBox and made the sizedbox horizontally scorllable using SingleChildScrollView. The code looks like this :-
The width of sizedbox is horizontalLength, and its value is set dynamically as (datasize+2) * (blankSpaceWidth + barWidth).
blankSpaceWidth is the groupsSpace between two consecutive groups of bars and barWidth is the width of each bar. DataSize is basically the number of datapoints or number of bar groups. I have taken datasize+2 here, so as to provide adequate empty space before first bar and after last bar. The final output looks like this :-
https://github.com/imaNNeo/fl_chart/assets/56213335/3f443b8a-168f-42b3-991b-ebe9d2dc434e
I Wrapped my Barchart inside a SizedBox and made the sizedbox horizontally scorllable using SingleChildScrollView. The code looks like this :-
The width of sizedbox is horizontalLength, and its value is set dynamically as (datasize+2) * (blankSpaceWidth + barWidth).
blankSpaceWidth is the groupsSpace between two consecutive groups of bars and barWidth is the width of each bar. DataSize is basically the number of datapoints or number of bar groups. I have taken datasize+2 here, so as to provide adequate empty space before first bar and after last bar. The final output looks like this :-
Flutter.Demo.-.Profile.1.-.Microsoft.Edge.2023-09-17.13-11-24.mp4
Achieved similar results with LineChart.
Here, I have set the horizontalLength as (datasize+2) * 50
https://github.com/imaNNeo/fl_chart/assets/56213335/14a5c153-7350-4d3b-8cd9-d0154952e2d7
The owner of this bag has no time and needs money
A glance at the comments section reveals two main options
plan 1 . Give yourself a large SizedBox and wrap it externally with SingleChildScrollView plan 2 . Change the source code of the package yourself You are welcome to add plan
Yeah those are pretty much the solutions. Maybe we can create a pull request with these solutions
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
import 'dart:math'; import 'package:flutter/material.dart'; class ZoomableChart extends StatefulWidget { ZoomableChart({ super.key, required this.maxX, required this.builder, }); double maxX; Widget Function(double, double) builder; @override State<ZoomableChart> createState() => _ZoomableChartState(); } class _ZoomableChartState extends State<ZoomableChart> { late double minX; late double maxX; late double lastMaxXValue; late double lastMinXValue; @override void initState() { super.initState(); minX = 0; maxX = widget.maxX; } @override Widget build(BuildContext context) { return GestureDetector( onDoubleTap: () { setState(() { minX = 0; maxX = widget.maxX; }); }, onHorizontalDragStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; }, onHorizontalDragUpdate: (details) { var horizontalDistance = details.primaryDelta ?? 0; if (horizontalDistance == 0) return; print(horizontalDistance); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0.0); setState(() { minX -= lastMinMaxDistance * 0.005 * horizontalDistance; maxX -= lastMinMaxDistance * 0.005 * horizontalDistance; if (minX < 0) { minX = 0; maxX = lastMinMaxDistance; } if (maxX > widget.maxX) { maxX = widget.maxX; minX = maxX - lastMinMaxDistance; } print("$minX, $maxX"); }); }, onScaleStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; }, onScaleUpdate: (details) { var horizontalScale = details.horizontalScale; if (horizontalScale == 0) return; print(horizontalScale); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0); var newMinMaxDistance = max(lastMinMaxDistance / horizontalScale, 10); var distanceDifference = newMinMaxDistance - lastMinMaxDistance; print("$lastMinMaxDistance, $newMinMaxDistance, $distanceDifference"); setState(() { final newMinX = max( lastMinXValue - distanceDifference, 0.0, ); final newMaxX = min( lastMaxXValue + distanceDifference, widget.maxX, ); if (newMaxX - newMinX > 2) { minX = newMinX; maxX = newMaxX; } print("$minX, $maxX"); }); }, child: widget.builder(minX, maxX), ); } }
Copy and paste that code in a file like
zoomable_chart.dart
. Then wrap your chart with ZoomableChart, provide a maxX and a function that returns your widget.Example
return AspectRatio( aspectRatio: 16 / 9, child: ZoomableChart( maxX: /* maxX OF YOUR DATA HERE */, builder: (minX, maxX) { return LineChart( LineChartData( clipData: FlClipData.all(), minX: minX, maxX: maxX, lineTouchData: LineTouchData(enabled: false), lineBarsData: [ LineChartBarData( spots: /* DATA HERE */, isCurved: false, barWidth: 2, color: line2Color, dotData: FlDotData( show: false, ), ), ], minY: 1, borderData: FlBorderData( show: false, ), ), ); }, ), );
Working demo
Screen.Recording.2023-02-02.at.16.36.18.mov
How did you achieve that the x axis label changes depended on the zoom level? Also you loose the touch event to see the values
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
Have also modified @TeoVogel 's solution a bit so that pinch/trackpad zoom will track from the location you pinch, rather than from center of graph. I was too lazy to remove my double tap feature, which zooms to an x axis width of ± 50 from the point on the graph that was clicked. maybe that will be useful to someone too : ) confirmed works on iOS and macOS.
note the 65 offset in my code, which is specific to the margin and padding I applied to my chart. The commented out onTapDown function at the bottom can help you find your offset.
class ZoomableChart extends StatefulWidget {
ZoomableChart({
super.key,
required this.maxX,
required this.builder,
this.touchpadScroll = false,
});
double maxX;
bool touchpadScroll;
Widget Function(double, double) builder;
@override
State<ZoomableChart> createState() => _ZoomableChartState();
}
class _ZoomableChartState extends State<ZoomableChart> {
late double minX;
late double maxX;
late double lastMaxXValue;
late double lastMinXValue;
double focalPoint = -1;
bool isZoomed = false;
late RenderBox renderBox;
late double chartW;
late Offset position;
late double currPosition;
@override
void initState() {
super.initState();
minX = 0;
maxX = widget.maxX;
WidgetsBinding.instance.addPostFrameCallback((_) {
renderBox = context.findRenderObject() as RenderBox;
chartW = renderBox.size.width;
position = renderBox.localToGlobal(Offset.zero);
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTapDown: (details) {
setState(() {
if (isZoomed) {
minX = 0;
maxX = widget.maxX;
} else {
renderBox = context.findRenderObject() as RenderBox;
chartW = renderBox.size.width - 65; // <-- you will need to figure out the offset from the edge of this widget to actual graph.
position = renderBox.localToGlobal(Offset.zero);
currPosition = details.localPosition.dx - 65; // <-----
double currPositionX = (currPosition / chartW)*(maxX - minX) + minX;
minX = currPositionX - 50;
maxX = currPositionX + 50;
}
isZoomed = !isZoomed;
});
},
trackpadScrollToScaleFactor: kDefaultTrackpadScrollToScaleFactor,
onHorizontalDragStart: (details) {
lastMinXValue = minX;
lastMaxXValue = maxX;
},
onHorizontalDragUpdate: (details) {
var horizontalDistance = details.primaryDelta ?? 0;
if (horizontalDistance == 0) return;
// print('distance : $horizontalDistance');
var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0.0);
// print(lastMinMaxDistance);
setState(() {
minX -= lastMinMaxDistance * 0.003 * horizontalDistance;
maxX -= lastMinMaxDistance * 0.003 * horizontalDistance;
if (minX <= 0) {
minX = 0;
maxX = lastMinMaxDistance;
}
if (maxX > widget.maxX) {
maxX = widget.maxX;
minX = maxX - lastMinMaxDistance;
}
// print("hordrag update x: $minX, $maxX");
});
},
onScaleStart: (details) {
lastMinXValue = minX;
lastMaxXValue = maxX;
renderBox = context.findRenderObject() as RenderBox;
chartW = renderBox.size.width - 65; // <-- you will need to figure out the offset from the edge of this widget to actual graph.
position = renderBox.localToGlobal(Offset.zero);
currPosition = details.localFocalPoint.dx - 65; // <-----
},
onScaleUpdate: (details) {
double leftUpdateFactor = currPosition / chartW;
double rightUpdateFactor = 1 - leftUpdateFactor;
var horizontalScale = details.horizontalScale;
if (horizontalScale == 0) return;
// print(horizontalScale);
var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0);
var newMinMaxDistance = max(lastMinMaxDistance / horizontalScale, 10);
var distanceDifference = newMinMaxDistance - lastMinMaxDistance;
// print("sss $lastMinMaxDistance, $newMinMaxDistance, $distanceDifference");
setState(() {
focalPoint = lastMinXValue + leftUpdateFactor * lastMinMaxDistance;
final newMinX = max(
lastMinXValue - distanceDifference * leftUpdateFactor,
0.0,
);
final newMaxX = min(
lastMaxXValue + distanceDifference * rightUpdateFactor,
widget.maxX,
);
if (newMaxX - newMinX > 2) {
minX = newMinX;
maxX = newMaxX;
}
// print("window X: $minX, $maxX");
});
},
onScaleEnd: (details) {
// print('scale ended');
setState(() {});
},
// onTapDown: (details) {
// print(details);
// print(chartW);
// print('local clicked position: ${details.localPosition.dx}');
// },
child: widget.builder(minX, maxX),
);
}
}
I improved over @Abhilash-Chandran solution. I made a wrapper widget for charts that use the minX and maxX parameters. It works great on mobile, supports double tap, pinch zoom and drag. Works only on the X axis (because that's what I need) but it should be easy to extend.
Have also modified @TeoVogel 's solution a bit so that pinch/trackpad zoom will track from the location you pinch, rather than from center of graph. I was too lazy to remove my double tap feature, which zooms to an x axis width of ± 50 from the point on the graph that was clicked. maybe that will be useful to someone too : ) confirmed works on iOS and macOS.
note the 65 offset in my code, which is specific to the margin and padding I applied to my chart. The commented out onTapDown function at the bottom can help you find your offset.
class ZoomableChart extends StatefulWidget { ZoomableChart({ super.key, required this.maxX, required this.builder, this.touchpadScroll = false, }); double maxX; bool touchpadScroll; Widget Function(double, double) builder; @override State<ZoomableChart> createState() => _ZoomableChartState(); } class _ZoomableChartState extends State<ZoomableChart> { late double minX; late double maxX; late double lastMaxXValue; late double lastMinXValue; double focalPoint = -1; bool isZoomed = false; late RenderBox renderBox; late double chartW; late Offset position; late double currPosition; @override void initState() { super.initState(); minX = 0; maxX = widget.maxX; WidgetsBinding.instance.addPostFrameCallback((_) { renderBox = context.findRenderObject() as RenderBox; chartW = renderBox.size.width; position = renderBox.localToGlobal(Offset.zero); }); } @override Widget build(BuildContext context) { return GestureDetector( onDoubleTapDown: (details) { setState(() { if (isZoomed) { minX = 0; maxX = widget.maxX; } else { renderBox = context.findRenderObject() as RenderBox; chartW = renderBox.size.width - 65; // <-- you will need to figure out the offset from the edge of this widget to actual graph. position = renderBox.localToGlobal(Offset.zero); currPosition = details.localPosition.dx - 65; // <----- double currPositionX = (currPosition / chartW)*(maxX - minX) + minX; minX = currPositionX - 50; maxX = currPositionX + 50; } isZoomed = !isZoomed; }); }, trackpadScrollToScaleFactor: kDefaultTrackpadScrollToScaleFactor, onHorizontalDragStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; }, onHorizontalDragUpdate: (details) { var horizontalDistance = details.primaryDelta ?? 0; if (horizontalDistance == 0) return; // print('distance : $horizontalDistance'); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0.0); // print(lastMinMaxDistance); setState(() { minX -= lastMinMaxDistance * 0.003 * horizontalDistance; maxX -= lastMinMaxDistance * 0.003 * horizontalDistance; if (minX <= 0) { minX = 0; maxX = lastMinMaxDistance; } if (maxX > widget.maxX) { maxX = widget.maxX; minX = maxX - lastMinMaxDistance; } // print("hordrag update x: $minX, $maxX"); }); }, onScaleStart: (details) { lastMinXValue = minX; lastMaxXValue = maxX; renderBox = context.findRenderObject() as RenderBox; chartW = renderBox.size.width - 65; // <-- you will need to figure out the offset from the edge of this widget to actual graph. position = renderBox.localToGlobal(Offset.zero); currPosition = details.localFocalPoint.dx - 65; // <----- }, onScaleUpdate: (details) { double leftUpdateFactor = currPosition / chartW; double rightUpdateFactor = 1 - leftUpdateFactor; var horizontalScale = details.horizontalScale; if (horizontalScale == 0) return; // print(horizontalScale); var lastMinMaxDistance = max(lastMaxXValue - lastMinXValue, 0); var newMinMaxDistance = max(lastMinMaxDistance / horizontalScale, 10); var distanceDifference = newMinMaxDistance - lastMinMaxDistance; // print("sss $lastMinMaxDistance, $newMinMaxDistance, $distanceDifference"); setState(() { focalPoint = lastMinXValue + leftUpdateFactor * lastMinMaxDistance; final newMinX = max( lastMinXValue - distanceDifference * leftUpdateFactor, 0.0, ); final newMaxX = min( lastMaxXValue + distanceDifference * rightUpdateFactor, widget.maxX, ); if (newMaxX - newMinX > 2) { minX = newMinX; maxX = newMaxX; } // print("window X: $minX, $maxX"); }); }, onScaleEnd: (details) { // print('scale ended'); setState(() {}); }, // onTapDown: (details) { // print(details); // print(chartW); // print('local clicked position: ${details.localPosition.dx}'); // }, child: widget.builder(minX, maxX), ); } }
I cannot get this to work. Is there a minimum amount of spots/datapoints needed for zoom to work? I wrapped my function which returns LineChartData with ZoomableChart. I'm unsure what kind of value I should use with maxX.
EDIT: I got it to work, but I don't understand how to configure the max zoom level. I got 11 points and the "closest" I can zoom to still has all of them in view. I can zoom out a lot.
Still trying to figure out how to display the datapoints' data when the lineTouchData is disabled. If I enable it the zoomable chart doesn't work but at least I can view the labels.
If anyone wants to achieve panning and mousewheel zooming, following code might help. I have tested the following in flutter for windows and browser. In windows it works well and satisfies my needs. In browser however the panning is not good because of
DragupdateDetails.primaryDelta
comes in as0
quite often and hence the panning is jittery.Note this logic still has flaws like minX and maxX not being clamped to stop zooming etc. However I feel this is good start.
@imaNNeoFighT I am not sure if this is a performant way, but seems to achieve some results. Atleast in windows I didn't feel any jitter. 😄
Idea is as follows.
Use a
Listener
widget to listen to mouse scroll events.
- Increment and decrement minx and maxx by a fixed percentage of maxX, depending on the scroll direction.
- Use a
GestureDetector
widget to detect horizontal drag event.
- decrement both minX and maxX by a percentage if panning to the left. that is if primary delta is negative.
- Increment both minX and maxX by a percentage if panning to the right. that is if primary delta is positive.
- Finally clip the plot to be within the bounds using the
clipData: FlClipData.all()
of theLineChartData
. without this the plot is rendered outside the widget.Following example achieves panning and zooming only in x-axis. However the logic can be extended to yaxis as well.
import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class PlotData { List<double> result; double maxY; double minY; PlotData({ required this.result, required this.maxY, required this.minY, }); } class LinePlot extends StatefulWidget { final PlotData plotData; const LinePlot({ required this.plotData, Key? key, }) : super(key: key); @override _LinePlotState createState() => _LinePlotState(); } class _LinePlotState extends State<LinePlot> { late double minX; late double maxX; @override void initState() { super.initState(); minX = 0; maxX = widget.plotData.result.length.toDouble(); } @override Widget build(BuildContext context) { return Listener( onPointerSignal: (signal) { if (signal is PointerScrollEvent) { setState(() { if (signal.scrollDelta.dy.isNegative) { minX += maxX * 0.05; maxX -= maxX * 0.05; } else { minX -= maxX * 0.05; maxX += maxX * 0.05; } }); } }, child: GestureDetector( onDoubleTap: () { setState(() { minX = 0; maxX = widget.plotData.result.length.toDouble(); }); }, onHorizontalDragUpdate: (dragUpdDet) { setState(() { print(dragUpdDet.primaryDelta); double primDelta = dragUpdDet.primaryDelta ?? 0.0; if (primDelta != 0) { if (primDelta.isNegative) { minX += maxX * 0.005; maxX += maxX * 0.005; } else { minX -= maxX * 0.005; maxX -= maxX * 0.005; } } }); }, child: LineChart( LineChartData( minX: minX, maxX: maxX, maxY: widget.plotData.maxY + widget.plotData.maxY * 0.1, titlesData: FlTitlesData( bottomTitles: SideTitles( showTitles: true, interval: widget.plotData.result.length / 10, ), leftTitles: SideTitles( showTitles: true, margin: 5, ), topTitles: SideTitles( showTitles: false, margin: 5, ), ), gridData: FlGridData( drawHorizontalLine: false, ), clipData: FlClipData.all(), lineBarsData: [ LineChartBarData( barWidth: 1, dotData: FlDotData( show: false, ), spots: widget.plotData.result .asMap() .entries .map((entry) => FlSpot(entry.key.toDouble(), entry.value)) .toList(), ) ], ), ), ), ); } }
i create XAxisScrollableChart base on this, just support x axis scroll
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
typedef XAxisRangeWidgetBuilder = Widget Function(double minX, double maxX);
class XAxisScrollableChart extends StatefulWidget {
final double minX;
final double maxX;
final int visibleXSize;
final XAxisRangeWidgetBuilder itemBuilder;
final double xScrollOffset;
const XAxisScrollableChart({
required this.minX,
required this.maxX,
required this.itemBuilder,
this.visibleXSize = 5,
this.xScrollOffset = 0.01,
Key? key,
}) : super(key: key);
@override
State<XAxisScrollableChart> createState() => _LinePlotState();
}
class _LinePlotState extends State<XAxisScrollableChart> {
late double minX;
late double maxX;
late double xRange;
late double currentMinX;
late double currentMaxX;
late int visibleRange;
@override
void initState() {
super.initState();
_handleX();
}
@override
void didUpdateWidget(covariant XAxisScrollableChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.minX != oldWidget.minX || widget.maxX != oldWidget.maxX) {
setState(() {
_handleX();
});
}
}
void _handleX() {
visibleRange = widget.visibleXSize - 1;
minX = widget.minX;
maxX = widget.maxX;
xRange = maxX -= minX;
currentMaxX = maxX;
currentMinX = maxX - visibleRange;
if (currentMinX < minX) {
currentMinX = minX;
currentMaxX = minX + visibleRange;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (dragUpdDet) {
double primaryDelta = dragUpdDet.primaryDelta ?? 0.0;
double tempMinX = currentMinX;
double tempMaxX = currentMaxX;
if (primaryDelta != 0) {
if (primaryDelta.isNegative) {
tempMinX += xRange * widget.xScrollOffset;
tempMaxX += xRange * widget.xScrollOffset;
} else {
tempMinX -= xRange * widget.xScrollOffset;
tempMaxX -= xRange * widget.xScrollOffset;
}
}
if (tempMinX < minX) {
setState(() {
currentMinX = minX;
});
return;
}
if (tempMaxX > maxX) {
setState(() {
currentMaxX = maxX;
});
return;
}
setState(() {
currentMinX = tempMinX;
currentMaxX = tempMaxX;
});
},
child: widget.itemBuilder(currentMinX, currentMaxX),
);
}
}
usage
XAxisScrollableChart(
minX: 0,
maxX: spotsData.isEmpty ? 0 : (spotsData.length.toDouble() - 1),
itemBuilder: (double minX, double maxX) {
return YourLineChart;
},
),
Bar chart is very compact in nature if there are many values on the x-axis. How to make it less compact if there are so many values on x-axis. I want it to horizontal scrollable?