Skip to content

InteractiveViewer focalLocalPoint doesn't give correct location in the child container when zoomed in or scaled #145215

@MuhammedOzdogan

Description

@MuhammedOzdogan

Steps to reproduce

  1. Click on a specific location in InteractiveViewer and check ScaleStartDetails.localFocalPoint data.
  2. Zoom the clicked location
  3. Click again at the same point while zoomed
  4. Check again ScaleStartDetails.localFocalPoint it's different from the first one

Expected results

ScaleStartDetails.localFocalPoint should point the exact same location of the child container whether InteractiveViewer zoomed or not.

Actual results

ScaleStartDetails.localFocalPoint changes when zoomed and point incorrect location in the child container.

Code sample

Code sample
import 'package:flutter/material.dart';
import 'dart:ui' as ui;

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Painting',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'CustomPainter in InteractiveViewer'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  late final TransformationController _transformationController;
  final List<Painting> _stack = [];
  PaintingMode _mode = PaintingMode.Line;

  @override
  void initState() {
    super.initState();
    _transformationController = TransformationController();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
        actions: [
          IconButton(
            onPressed: () {
              if (_mode != PaintingMode.Pan) {
                setState(() {
                  _mode = PaintingMode.Pan;
                });
              }
            },
            icon: const Icon(Icons.pan_tool),
            iconSize: 24,
            color: _mode == PaintingMode.Pan
                ? Theme.of(context).colorScheme.primary
                : Theme.of(context).colorScheme.secondary,
          ),
          IconButton(
            onPressed: () {
              if (_mode != PaintingMode.RRectangular) {
                setState(() {
                  _mode = PaintingMode.RRectangular;
                });
              }
            },
            icon: const Icon(Icons.crop_square),
            iconSize: 24,
            color: _mode == PaintingMode.RRectangular
                ? Theme.of(context).colorScheme.primary
                : Theme.of(context).colorScheme.secondary,
          ),
          IconButton(
            onPressed: () {
              if (_mode != PaintingMode.Line) {
                setState(() {
                  _mode = PaintingMode.Line;
                });
              }
            },
            icon: const Icon(Icons.draw),
            iconSize: 24,
            color: _mode == PaintingMode.Line
                ? Theme.of(context).colorScheme.primary
                : Theme.of(context).colorScheme.secondary,
          ),
          TextButton(
              onPressed: () {
                _stack.clear();
              },
              child: const Text("Clear")),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(
              child: FittedBox(
                child: ClipRect(
                  child: SizedBox(
                    width: MediaQuery.of(context).size.width,
                    height: MediaQuery.of(context).size.height * 0.8,
                    child: InteractiveViewer(
                      transformationController: _transformationController,
                      scaleEnabled: _mode == PaintingMode.Pan,
                      panEnabled: _mode == PaintingMode.Pan,
                      onInteractionStart: (details) =>
                          _onPanStart(Colors.red, 8, details),
                      onInteractionUpdate: (details) => _onPanUpdate(details),
                      child: CustomPaint(
                        painter: Painter(_stack),
                      ),
                    ),
                  ),
                ),
              ),
            ),
            Padding(
              padding: EdgeInsets.all(16),
              child: Text(
                "localFocalPoint is not correct while zoomed",
                style: Theme.of(context).textTheme.headlineLarge,
              ),
            )
          ],
        ),
      ),
    );
  }

  _onPanStart(Color color, double stroke, ScaleStartDetails details) {
    if (_mode == PaintingMode.Pan) {
      return;
    }
    final scene = _transformationController.toScene(details.focalPoint);
    print("""
        Panning started,
        scene: $scene,
        focal: ${details.focalPoint},
        focalLocal: ${details.localFocalPoint},
        """);
    _paintingStart(details.localFocalPoint, color, stroke);
  }

  void _onPanUpdate(ScaleUpdateDetails details) {
    if (_mode == PaintingMode.Pan) {
      return;
    }
    final scene = _transformationController.toScene(details.focalPoint);
    print("""
        Panning started,
        scene: $scene,
        focal: ${details.focalPoint},
        focalLocal: ${details.localFocalPoint},
        focalDelta: ${details.focalPointDelta},

        delta: ${details.focalPointDelta},
        """);
    _paintingUpdate(details.localFocalPoint);
  }

  void _paintingStart(Offset startPoint, Color color, double stroke) {
    if (_mode == PaintingMode.Line) {
      setState(() {
      _stack.add(Painting([startPoint], color, stroke, _mode));
      });
    } else if (_mode == PaintingMode.RRectangular) {
      // Make left top and right bottom corner same point if user only taps once
      setState(() {
      _stack.add(Painting([startPoint, startPoint], color, stroke, _mode));
      });
    }
  }

  void _paintingUpdate(Offset value) {
    final index = _stack.length - 1;
    if (_mode == PaintingMode.Line) {
      // Add every point
      setState(() {
      _stack[index].points.add(value);
      });
    } else if (_mode == PaintingMode.RRectangular) {
      // Update right bottom corner
      setState(() {
      _stack[index].points[1] = value;
      });
    }
  }
}

enum PaintingMode {
  Pan,
  Line,
  RRectangular;
}

// Data class represent drawing event
class Painting {
  final List<Offset> points;
  final Color color;
  final double stroke;
  final PaintingMode mode;

  Painting(this.points, this.color, this.stroke, this.mode);
}

class Painter extends CustomPainter {
  final List<Painting> _paintings;

  Painter(this._paintings);

  @override
  void paint(ui.Canvas canvas, Size size) {
    if (_paintings.isEmpty) return;

    for (var painting in _paintings) {
      if (painting.mode == PaintingMode.Line) {
        _drawLine(canvas, painting);
      } else if (painting.mode == PaintingMode.RRectangular) {
        _drawRRect(canvas, painting);
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

  _drawRRect(ui.Canvas canvas, Painting painting) {
    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = painting.color
      ..isAntiAlias = true
      ..strokeWidth = painting.stroke;

    final topLeftCornerOffset = painting.points[0];
    final topRightCornerOffset = painting.points[1];
    final rect = Rect.fromPoints(topLeftCornerOffset, topRightCornerOffset);
    final rrect = RRect.fromRectAndRadius(rect, Radius.circular(8));

    canvas.drawRRect(rrect, paint);
  }

  _drawLine(ui.Canvas canvas, Painting painting) {
    final paint = Paint()
      ..color = painting.color
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true
      ..strokeWidth = painting.stroke;
    for (var i = 0; i < painting.points.length; i++) {
      if (i < painting.points.length - 1) {
        canvas.drawLine(painting.points[i], painting.points[i + 1], paint);
      } else {
        canvas.drawPoints(ui.PointMode.points, [painting.points[i]], paint);
      }
    }
  }
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Screen.Recording.2024-03-15.at.13.29.40.mov

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.19.3, on macOS 14.2.1 23C71 darwin-arm64, locale en-TR)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2022.3)
[✓] IntelliJ IDEA Community Edition (version 2023.3.3)
[✓] VS Code (version 1.87.2)
[✓] Connected device (2 available)
[✓] Network resources

• No issues found!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions