Search code examples
flutterdrawingscale

Keep a widget at a relative point even when image get's scaled


Since a few days I'm trying to keep a drawn circle (using a CustomPainter) on an image while the image gets scaled (zoom in/out). To render the image and enable the zoom gesture I use photo_view. I can move the circle according to the change of the view port as long as nothing gets scaled. All values (scale,offset) are provided by the PhotoViewController as a stream as shown below:

main.dart

import 'package:backtrack/markerPainter.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'dart:math' as math;

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  Offset position;
  Offset startPosition;
  Offset scaleChange;
  Offset offsetToCenter;
  double initialScale;
  Offset forPoint;
  PhotoViewScaleStateController controller;
  PhotoViewController controller2;
  double lastscale;
  @override
  void initState() {
    super.initState();
    controller = PhotoViewScaleStateController();
    controller2 = PhotoViewController();
    controller2.outputStateStream.listen((onData) {
     // print("position change: " + onData.position.toString());
     // print("scale change: " + onData.scale.toString());
      if (position != null) {
        setState(() {
          final scaleToUser = initialScale + (initialScale - onData.scale);
          //Change to the unscaled point inverted to reflect the opposite movement of the user
          final newOffset = (offsetToCenter - onData.position) * -1;
          print("new offset: " + newOffset.toString());

          if (onData.scale != lastscale) {
            if (onData.scale > initialScale) {
              //zoom in
              var zoomeChnage = initialScale-onData.scale;
              scaleChange = startPosition * zoomeChnage;
              print("new scaleChange: " + scaleChange.toString());
            } else {
              //zoom out
            }
            lastscale = onData.scale;
          }
          forPoint = newOffset-scaleChange; 
          print("new forPoint: " + forPoint.toString());
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: _buildMap(context));
  }

  Widget _buildMap(BuildContext context) {
    return Container(
      child: CustomPaint(
        foregroundPainter: MyPainter(position, forPoint),
        child: PhotoView(
          imageProvider: AssetImage("asset/map.JPG"),
          onTapUp: (a, b, c) {
            setState(() {
              position = b.localPosition;
              offsetToCenter = c.position;
              forPoint = Offset.zero;
              initialScale = c.scale;
              print("localPosition: " + b.localPosition.toString());
              print("offsetToCenter: " + offsetToCenter.toString());
              print("initialScale: " + initialScale.toString());
            });
          },
          scaleStateController: controller,
          controller: controller2,
        ),
      ),
    );
  }
}

Just in case, below is the widget that renders the circle on top of the image. markerPainter.dart

class MyPainter extends CustomPainter {
  final Offset position;
  final Offset centerOffset;
  final Paint line;
  MyPainter(this.position, this.centerOffset)
      : line = new Paint()
          ..color = Colors.pink
          ..style = PaintingStyle.fill
          ..strokeWidth = 5;

  @override
  void paint(Canvas canvas, Size size) {
    if (position == null) {
      return;
    }

    canvas.drawCircle(position + centerOffset, 5, line);
  }

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

I really tried a lot but it looks like my brain can't solve it, any help is very welcome.


Solution

  • I found a solution, using this helper class I can calculate a relative point for an absolute point:

      FlatMapState(
          {this.imageScale,
          this.initialViewPort,
          this.imageSize,
          this.viewPortDelta,
          this.viewPortOffsetToCenter,
          this.currentViewPort});
    
      Offset absolutePostionToViewPort(Offset absolutePosition) {
        var relativeViewPortPosition =
            ((imageSize * imageScale).center(Offset.zero) -
                    absolutePosition * imageScale) *
                -1;
        relativeViewPortPosition += viewPortOffsetToCenter;
        return relativeViewPortPosition + viewPortDelta / -2;
      }
    }
    

    The class gets more or less all information from the PhotoViewController update event like this:

    bool _photoViewValueIsValid(PhotoViewControllerValue value) {
        return value != null && value.position != null && value.scale != null;
      }
    
      FlatMapState createCurrentState(PhotoViewControllerValue photoViewValue) {
        if (_photoViewValueIsValid(photoViewValue) && imageSize != null) {
          if (lastViewPort == null) {
            lastViewPort = stickyKey.currentContext.size;
          }
          return FlatMapState(
            imageScale: photoViewValue.scale,
            imageSize: imageSize,
            initialViewPort: lastViewPort,
            viewPortDelta: Offset(
                lastViewPort.width - stickyKey.currentContext.size.width,
                lastViewPort.height - stickyKey.currentContext.size.height),
            viewPortOffsetToCenter: photoViewValue.position,
            currentViewPort: Offset(stickyKey.currentContext.size.width,
                stickyKey.currentContext.size.height),
          );
        } else {
          //The map or image is still loading, the state can't be generated
          return null;
        }
      }
    

    Please note that the calculated value has to be drawn by setting the point of origin to the center like this:

     @override
      void paint(Canvas canvas, Size size) {
        //In case the state is null or nothing to draw leave
        if(flatMapState == null || drawableElements == null){
          return;
        }
        //Center the canvas (0,0) is now the center, done to have the same origion as photoview
        var viewPortCenter = flatMapState.initialViewPort.center(Offset.zero);
        canvas.translate(viewPortCenter.dx, viewPortCenter.dy);
        drawableElements.forEach((drawable) => drawable.draw(
              canvas,
              size,
              flatMapState,
            ));
      }