Repository: imaNNeo/fl_chart (dart)

Issue:
Initial zoom issue
**Describe the bug**
Whenever a chart is built with an initial zoom, the not-zoomed chart is built in the first frame, and the zoom is only applied after that.

**To Reproduce**
```dart
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _controller = TransformationController(Matrix4.identity()..scale(1.1));

  bool _show = false;

  void _toggle() {
    setState(() {
      _show = !_show;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _show
          ? LineChart(
              LineChartData(lineBarsData: [
                LineChartBarData(
                  spots: [
                    FlSpot(0, 1),
                    FlSpot(1, 2),
                    FlSpot(2, 3),
                    FlSpot(3, 2),
                    FlSpot(4, 1),
                  ],
                )
              ]),
              transformationConfig: FlTransformationConfig(
                transformationController: _controller,
                scaleAxis: FlScaleAxis.free,
              ),
            )
          : SizedBox(),
      floatingActionButton: FloatingActionButton(onPressed: _toggle),
    );
  }
}

```

**Video**

https://github.com/user-attachments/assets/f3eec14d-a599-49fb-8e4d-1600e03eaeae

**Versions**
- Flutter: 3.27.3
- FlChart: 0.70.2

I am planning to open a PR.

Pull request description:
Fixes #1863.

**Changes**

This PR eliminates the post frame function in `AxisChartScaffoldWidget` to calculate the `virtualRect`, instead uses the `constraints` of `LayoutBuilder` and other data available in `_stackWidgets`.

**Testing**

Since there is no need to wait 1 frame for the _zoom_ to be applied, I removed the related `tester.pump` and `expect` function calls.

I removed the `post frame callback checks if widget is mounted` test, as it is not needed anymore afaik.

I am not a 100% sure about changing `updates chart rect after the first frame when controller scale != 1.0` to `Initializes zoomed chart rect when controller scale != 1.0` though, as I don't know the exact purpose of the original test.

Patch:
diff --git a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart
index 7774dd205..3e9628a51 100644
--- a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart
+++ b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart
@@ -64,8 +64,6 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
 
   final _chartKey = GlobalKey();
 
-  Rect? _chartVirtualRect;
-
   FlTransformationConfig get _transformationConfig =>
       widget.transformationConfig;
 
@@ -83,13 +81,12 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
     _transformationController =
         _transformationConfig.transformationController ??
             TransformationController();
-    _transformationController.addListener(_updateChartVirtualRect);
-    updateRectPostFrame();
+    _transformationController.addListener(_transformationControllerListener);
   }
 
   @override
   void dispose() {
-    _transformationController.removeListener(_updateChartVirtualRect);
+    _transformationController.removeListener(_transformationControllerListener);
     if (_transformationConfig.transformationController == null) {
       _transformationController.dispose();
     }
@@ -110,29 +107,29 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
         _transformationController.dispose();
         _transformationController =
             widget.transformationConfig.transformationController!;
-        _transformationController.addListener(_updateChartVirtualRect);
+        _transformationController
+            .addListener(_transformationControllerListener);
       case (TransformationController(), null):
-        _transformationController.removeListener(_updateChartVirtualRect);
+        _transformationController
+            .removeListener(_transformationControllerListener);
         _transformationController = TransformationController();
-        _transformationController.addListener(_updateChartVirtualRect);
+        _transformationController
+            .addListener(_transformationControllerListener);
       case (TransformationController(), TransformationController()):
         if (oldWidget.transformationConfig.transformationController !=
             widget.transformationConfig.transformationController) {
-          _transformationController.removeListener(_updateChartVirtualRect);
+          _transformationController
+              .removeListener(_transformationControllerListener);
           _transformationController =
               widget.transformationConfig.transformationController!;
-          _transformationController.addListener(_updateChartVirtualRect);
+          _transformationController
+              .addListener(_transformationControllerListener);
         }
     }
-
-    updateRectPostFrame();
   }
 
-  void updateRectPostFrame() {
-    WidgetsBinding.instance.addPostFrameCallback((_) {
-      if (!mounted) return;
-      _updateChartVirtualRect();
-    });
+  void _transformationControllerListener() {
+    setState(() {});
   }
 
   // Applies the inverse transformation to the chart to get the zoomed
@@ -142,45 +139,28 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
   // grow beyond the chart's boundaries when the chart is scaled in order
   // for its content to be laid out on the larger area. This leads to the
   // scaling effect.
-  void _updateChartVirtualRect() {
+  Rect? _calculateAdjustedRect(Rect rect) {
     final scale = _transformationController.value.getMaxScaleOnAxis();
     if (scale == 1.0) {
-      setState(() {
-        _chartVirtualRect = null;
-      });
-      return;
+      return null;
     }
     final inverseMatrix = Matrix4.inverted(_transformationController.value);
 
     final chartVirtualQuad = CustomInteractiveViewer.transformViewport(
       inverseMatrix,
-      _chartBoundaryRect,
+      rect,
     );
 
     final chartVirtualRect = CustomInteractiveViewer.axisAlignedBoundingBox(
       chartVirtualQuad,
     );
 
-    final adjustedRect = Rect.fromLTWH(
-      _canScaleHorizontally ? chartVirtualRect.left : _chartBoundaryRect.left,
-      _canScaleVertically ? chartVirtualRect.top : _chartBoundaryRect.top,
-      _canScaleHorizontally ? chartVirtualRect.width : _chartBoundaryRect.width,
-      _canScaleVertically ? chartVirtualRect.height : _chartBoundaryRect.height,
+    return Rect.fromLTWH(
+      _canScaleHorizontally ? chartVirtualRect.left : rect.left,
+      _canScaleVertically ? chartVirtualRect.top : rect.top,
+      _canScaleHorizontally ? chartVirtualRect.width : rect.width,
+      _canScaleVertically ? chartVirtualRect.height : rect.height,
     );
-
-    setState(() {
-      _chartVirtualRect = adjustedRect;
-    });
-  }
-
-  // The Rect representing the chart.
-  //
-  // This represents the actual size and offset of the chart.
-  Rect get _chartBoundaryRect {
-    assert(_chartKey.currentContext != null);
-    final childRenderBox =
-        _chartKey.currentContext!.findRenderObject()! as RenderBox;
-    return Offset.zero & childRenderBox.size;
   }
 
   bool get showLeftTitles {
@@ -219,15 +199,39 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
     return showAxisTitles || showSideTitles;
   }
 
-  List<Widget> stackWidgets(BoxConstraints constraints) {
+  List<Widget> _stackWidgets(BoxConstraints constraints) {
+    final margin = widget.data.titlesData.allSidesPadding;
+    final borderData = widget.data.borderData.isVisible()
+        ? widget.data.borderData.border
+        : null;
+
+    final borderWidth =
+        borderData == null ? 0 : borderData.dimensions.horizontal;
+    final borderHeight =
+        borderData == null ? 0 : borderData.dimensions.vertical;
+
+    final rect = Rect.fromLTRB(
+      0,
+      0,
+      constraints.maxWidth - margin.horizontal - borderWidth,
+      constraints.maxHeight - margin.vertical - borderHeight,
+    );
+
+    final adjustedRect = _calculateAdjustedRect(rect);
+
+    final virtualRect = switch (_transformationConfig.scaleAxis) {
+      FlScaleAxis.none => null,
+      FlScaleAxis() => adjustedRect,
+    };
+
     final chart = KeyedSubtree(
       key: _chartKey,
-      child: widget.chartBuilder(context, _chartVirtualRect),
+      child: widget.chartBuilder(context, virtualRect),
     );
 
-    final interactiveChart = LayoutBuilder(
-      builder: (context, constraints) {
-        return CustomInteractiveViewer(
+    final child = switch (_transformationConfig.scaleAxis) {
+      FlScaleAxis.none => chart,
+      FlScaleAxis() => CustomInteractiveViewer(
           transformationController: _transformationController,
           clipBehavior: Clip.none,
           trackpadScrollCausesScale:
@@ -237,26 +241,18 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
           panEnabled: _transformationConfig.panEnabled,
           scaleEnabled: _transformationConfig.scaleEnabled,
           child: SizedBox(
-            width: constraints.maxWidth,
-            height: constraints.maxHeight,
+            width: rect.width,
+            height: rect.height,
             child: chart,
           ),
-        );
-      },
-    );
+        ),
+    };
 
     final widgets = <Widget>[
       Container(
-        margin: widget.data.titlesData.allSidesPadding,
-        decoration: BoxDecoration(
-          border: widget.data.borderData.isVisible()
-              ? widget.data.borderData.border
-              : null,
-        ),
-        child: switch (_transformationConfig.scaleAxis) {
-          FlScaleAxis.none => chart,
-          FlScaleAxis() => interactiveChart,
-        },
+        margin: margin,
+        decoration: BoxDecoration(border: borderData),
+        child: child,
       ),
     ];
 
@@ -269,7 +265,7 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
           side: AxisSide.left,
           axisChartData: widget.data,
           parentSize: constraints.biggest,
-          chartVirtualRect: _chartVirtualRect,
+          chartVirtualRect: adjustedRect,
         ),
       );
     }
@@ -281,7 +277,7 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
           side: AxisSide.top,
           axisChartData: widget.data,
           parentSize: constraints.biggest,
-          chartVirtualRect: _chartVirtualRect,
+          chartVirtualRect: adjustedRect,
         ),
       );
     }
@@ -293,7 +289,7 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
           side: AxisSide.right,
           axisChartData: widget.data,
           parentSize: constraints.biggest,
-          chartVirtualRect: _chartVirtualRect,
+          chartVirtualRect: adjustedRect,
         ),
       );
     }
@@ -305,7 +301,7 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
           side: AxisSide.bottom,
           axisChartData: widget.data,
           parentSize: constraints.biggest,
-          chartVirtualRect: _chartVirtualRect,
+          chartVirtualRect: adjustedRect,
         ),
       );
     }
@@ -319,7 +315,7 @@ class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
         return RotatedBox(
           quarterTurns: widget.data.rotationQuarterTurns,
           child: Stack(
-            children: stackWidgets(constraints),
+            children: _stackWidgets(constraints),
           ),
         );
       },


Test patch:
diff --git a/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart b/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart
index 99a1ff5c7..74cf98b57 100644
--- a/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart
+++ b/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart
@@ -1244,7 +1244,7 @@ void main() {
     });
 
     testWidgets(
-      'updates chart rect after the first frame when controller scale != 1.0',
+      'Initializes zoomed chart rect when controller scale != 1.0',
       (WidgetTester tester) async {
         final controller = TransformationController(
           Matrix4.identity()..scale(3.0),
@@ -1258,6 +1258,7 @@ void main() {
                   data: lineChartDataWithNoTitles,
                   transformationConfig: FlTransformationConfig(
                     transformationController: controller,
+                    scaleAxis: FlScaleAxis.free,
                   ),
                   chartBuilder: (context, rect) {
                     chartVirtualRect = rect;
@@ -1269,49 +1270,10 @@ void main() {
           ),
         );
 
-        expect(chartVirtualRect, isNull);
-        await tester.pump();
         expect(chartVirtualRect, isNotNull);
       },
     );
 
-    testWidgets('post frame callback checks if widget is mounted',
-        (WidgetTester tester) async {
-      // This test only works correctly when the chart is a LineChart
-      // with scaleAxis set to none. Should throw assertion on "setState"
-      // if callback does not check if widget is mounted.
-
-      await tester.pumpWidget(
-        MaterialApp(
-          home: Scaffold(
-            body: ListView.builder(
-              itemCount: 20,
-              itemBuilder: (context, index) => SizedBox(
-                height: 300,
-                child: LineChart(
-                  lineChartDataWithNoTitles,
-                  // This is for test
-                  // ignore: avoid_redundant_argument_values
-                  transformationConfig: const FlTransformationConfig(
-                    // This is for test
-                    // ignore: avoid_redundant_argument_values
-                    scaleAxis: FlScaleAxis.none,
-                  ),
-                ),
-              ),
-            ),
-          ),
-        ),
-      );
-
-      await tester.drag(
-        find.byType(ListView),
-        const Offset(0, -1000),
-      );
-
-      await tester.pump();
-    });
-
     group('didUpdateWidget', () {
       const chartScaffoldKey = Key('chartScaffold');
 
@@ -1356,9 +1318,8 @@ void main() {
         'oldWidget.controller is null and widget.controller is null: '
         'keeps old controller',
         (WidgetTester tester) async {
-          final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+          final actualChartVirtualRects = <Object?>[isNotScaled];
           await tester.pumpWidget(createTestWidget());
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects);
 
           final transformationController = getTransformationController(tester);
@@ -1368,8 +1329,6 @@ void main() {
 
           await tester.pumpWidget(createTestWidget());
           expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
-          await tester.pump();
-          expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
 
           final transformationController2 = getTransformationController(tester);
           expect(transformationController2, transformationController);
@@ -1383,9 +1342,8 @@ void main() {
         'oldWidget.controller is null and widget.controller is not null: '
         'disposes old controller and sets up widget.controller with listeners',
         (WidgetTester tester) async {
-          final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+          final actualChartVirtualRects = <Object?>[isNotScaled];
           await tester.pumpWidget(createTestWidget());
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects);
 
           final transformationController = getTransformationController(tester);
@@ -1398,8 +1356,6 @@ void main() {
           await tester.pumpWidget(
             createTestWidget(controller: transformationController2),
           );
-          expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects..add(isNotScaled));
 
           expect(transformationController2, isNot(transformationController));
@@ -1418,12 +1374,11 @@ void main() {
         'removes listeners from old controller and sets up new controller '
         'with listeners',
         (WidgetTester tester) async {
-          final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+          final actualChartVirtualRects = <Object?>[isNotScaled];
           final transformationController = TransformationController();
           await tester.pumpWidget(
             createTestWidget(controller: transformationController),
           );
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects);
 
           transformationController.value = Matrix4.identity()..scale(2.0);
@@ -1431,8 +1386,6 @@ void main() {
           expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
 
           await tester.pumpWidget(createTestWidget());
-          expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects..add(isNotScaled));
 
           final transformationController2 = getTransformationController(tester);
@@ -1453,12 +1406,11 @@ void main() {
         'removes listeners from old controller and sets up '
         'widget.controller with listeners',
         (WidgetTester tester) async {
-          final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+          final actualChartVirtualRects = <Object?>[isNotScaled];
           final transformationController = TransformationController();
           await tester.pumpWidget(
             createTestWidget(controller: transformationController),
           );
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects);
 
           transformationController.value = Matrix4.identity()..scale(2.0);
@@ -1470,8 +1422,6 @@ void main() {
           await tester.pumpWidget(
             createTestWidget(controller: transformationController2),
           );
-          expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects..add(isNotScaled));
 
           expect(transformationController2, isNot(transformationController));
@@ -1489,14 +1439,13 @@ void main() {
         'oldWidget.controller is not null and widget.controller is not null, '
         'controllers are the same: keeps old controller',
         (WidgetTester tester) async {
-          final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+          final actualChartVirtualRects = <Object?>[isNotScaled];
           final transformationController = TransformationController();
           await tester.pumpWidget(
             createTestWidget(
               controller: transformationController,
             ),
           );
-          await tester.pump();
           expect(chartVirtualRects, actualChartVirtualRects);
 
           transformationController.value = Matrix4.identity()..scale(2.0);
@@ -1509,8 +1458,6 @@ void main() {
             ),
           );
           expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
-          await tester.pump();
-          expect(chartVirtualRects, actualChartVirtualRects..add(isScaled));
 
           final transformationController2 = getTransformationController(tester);
           expect(transformationController2, transformationController);
@@ -1526,13 +1473,14 @@ void main() {
       (WidgetTester tester) async {
         final transformationController = TransformationController();
         final chartVirtualRects = <Rect?>[];
-        final actualChartVirtualRects = <Object?>[isNotScaled, isNotScaled];
+        final actualChartVirtualRects = <Object?>[isNotScaled];
         await tester.pumpWidget(
           MaterialApp(
             home: AxisChartScaffoldWidget(
               data: lineChartDataWithNoTitles,
               transformationConfig: FlTransformationConfig(
                 transformationController: transformationController,
+                scaleAxis: FlScaleAxis.free,
               ),
               chartBuilder: (context, rect) {
                 chartVirtualRects.add(rect);
@@ -1541,7 +1489,6 @@ void main() {
             ),
           ),
         );
-        await tester.pump();
         expect(chartVirtualRects, actualChartVirtualRects);
 
         transformationController.value = Matrix4.identity()..scale(2.0);