Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to make scrollable x axis in flchart so that it looks nice not so compact in nature? #71

Open
nimesh1997 opened this issue Sep 28, 2019 · 51 comments
Labels
enhancement New feature or request Fundamental

Comments

@nimesh1997
Copy link

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?
Screenshot_20190928-152201

@nimesh1997 nimesh1997 changed the title How to make scrollable x axis in flchart so that it looks nice not compact? How to make scrollable x axis in flchart so that it looks nice not so compact in nature? Sep 28, 2019
@nimesh1997 nimesh1997 reopened this Sep 28, 2019
@imaNNeo
Copy link
Owner

imaNNeo commented Sep 28, 2019

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.

@imaNNeo imaNNeo closed this as completed Sep 28, 2019
@imaNNeo imaNNeo reopened this Sep 28, 2019
@imaNNeo
Copy link
Owner

imaNNeo commented Sep 28, 2019

I left it open, and people can thumb it up, then I will do it with high priority.

@imaNNeo imaNNeo added the enhancement New feature or request label Sep 28, 2019
@ZantsuRocks
Copy link

@nimesh1997
You can put it in a Container bigger than the screen and put that Container in a SingleChildScrollView with scrollDirection: Axis.horizontal

@imaNNeo
Copy link
Owner

imaNNeo commented Sep 30, 2019

@ZantsuRocks Greate solution, Thank you :)

@davicg
Copy link

davicg commented Oct 24, 2019

@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.

@Stitch-Taotao
Copy link

@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 .

@Shashwat-Joshi
Copy link

@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.

@Abhilash-Chandran
Copy link

Abhilash-Chandran commented Mar 28, 2021

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.

  1. 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.
  2. 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.
  1. Finally clip the plot to be within the bounds using the clipData: FlClipData.all() of the LineChartData. without this the plot is rendered outside the widget.

cnLXXH8TLX

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(),
              )
            ],
          ),
        ),
      ),
    );
  }
}

@jlubeck
Copy link
Contributor

jlubeck commented Jun 18, 2021

This seems to have a lot of thumbs up now, are there any plans to support it in the near future?
Thanks!

@imaNNeo
Copy link
Owner

imaNNeo commented Jul 2, 2021

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.

@KaranCodes95
Copy link

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.

@GivDavidL
Copy link

GivDavidL commented Jan 11, 2023

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

@roly151
Copy link

roly151 commented Jan 11, 2023

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,
          ),
        ),

@desmeit
Copy link

desmeit commented Jan 19, 2023

need this feature too.

@Pebsie
Copy link

Pebsie commented Jan 20, 2023

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.

@roly151
Copy link

roly151 commented Jan 22, 2023

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.

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.

@TeoVogel
Copy link

TeoVogel commented Feb 2, 2023

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

@imaNNeo
Copy link
Owner

imaNNeo commented Feb 4, 2023

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.

@aguilanbon
Copy link

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?

@TeoVogel
Copy link

TeoVogel commented Feb 7, 2023

@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

@stukdev
Copy link

stukdev commented Feb 22, 2023

@TeoVogel can you post a full example? I'm trying use your solution, but i've some trouble. Thanks

@GustavoDuregger
Copy link

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)

@zoo7314
Copy link

zoo7314 commented Jul 19, 2023

@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

@rydwan10
Copy link

Hello everyone, any update on this issue?

@imaNNeo
Copy link
Owner

imaNNeo commented Sep 14, 2023

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.

@aditya113141
Copy link

aditya113141 commented Sep 17, 2023

I Wrapped my Barchart inside a SizedBox and made the sizedbox horizontally scorllable using SingleChildScrollView. The code looks like this :-
Screenshot (69)

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

@aditya113141
Copy link

I Wrapped my Barchart inside a SizedBox and made the sizedbox horizontally scorllable using SingleChildScrollView. The code looks like this :- Screenshot (69)

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

Flutter.Demo.-.Profile.1.-.Microsoft.Edge.2023-09-17.14-39-58.mp4

@JuYiYang
Copy link

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

@jpgtzg
Copy link

jpgtzg commented Dec 20, 2023

Yeah those are pretty much the solutions. Maybe we can create a pull request with these solutions

@itaishalom
Copy link

itaishalom commented Dec 20, 2023

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

@austibwu
Copy link

austibwu commented Mar 4, 2024

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),
    );
  }
}

@juffis
Copy link

juffis commented Mar 13, 2024

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.

@mikaelzero
Copy link

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.

  1. 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.
  2. 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.
  1. Finally clip the plot to be within the bounds using the clipData: FlClipData.all() of the LineChartData. without this the plot is rendered outside the widget.

cnLXXH8TLX cnLXXH8TLX

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;
                },
            ),
                

@GanZhiXiong
Copy link

GanZhiXiong commented Apr 18, 2024

There is too much content above, let me summarize it.

  1. I have tried the above codes, but none of them work.
    Can anyone provide a demo that fully supports zooming and horizontal scrolling (you can put the code in the github warehouse or gist). It is recommended not to post it here, as it will affect reading other content.
  2. The author is working full-time now and has no time or energy to generate electricity for love. If you are able, please donate:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request Fundamental
Projects
None yet
Development

No branches or pull requests