bluefireteam / photo_view

📸 Easy to use yet very customizable zoomable image widget for Flutter, Photo View provides a gesture sensitive zoomable widget. Photo View is largely used to show interacive images and other stuff such as SVG.
MIT License
1.91k stars 547 forks source link

Keep an object at the same position with scaling and movement #239

Closed Dev-Owl closed 4 years ago

Dev-Owl commented 4 years ago

Hi,

I have one problem I simply don't get a solution for, using the onTap handler I can draw a circle at a position and move it according to the changes provided from PhotoViewController to keep it on the same spot in the image. I store the local position, the offset and scale when clicked, I draw the circle using position + forPoint.

//.....
onTapUp: (a, b, c) {
            setState(() {
              position = b.localPosition;
              offsetToCenter = c.position;
              forPoint = Offset.zero;
              initialScale = c.scale;
}
//.....
//Custom painter draw method
@override
  void paint(Canvas canvas, Size size) {
    if(position == null){
      return;
    }
    canvas.drawCircle(position+forPoint, 5, line);
  }
//.....

Calculating the move for the point is done in the listen method of the controller stream:

 controller2.outputStateStream.listen((onData) {
      print(onData.position.toString());
      print(onData.scale.toString());
      if (position != null) {
        setState(() {
          final newOffset = (offsetToCenter - onData.position) * -1;
          print("new offset: " + newOffset.toString());
          forPoint = newOffset;
        });
      }
    });

My issue is as soon as I change the scale the point moves, I spend more or less two days and a lot of paper but I can't get it right. Could somebody help me out and explain how to apply the scaling to keep the point at the right place in the image? Thanks

icelija commented 4 years ago

@renancaraujo, can you please help us with this one, I found that in this comment you mentioned this was solved but we are unable to understand how to get the exact position within PhotoView when the scale is bigger than 1.

I think this question is about the same thing so any help from you or @SergeShkurko who created that pull request would be very useful.

SergeShkurko commented 4 years ago

@icelija Example:

  1. first thing you need to figure out is the image resolution
    // helper for get image resolution
    Future<ui.Image> getImageInfo(ImageProvider<dynamic> provider) {
    final completer = Completer<ui.Image>();
    provider.resolve(ImageConfiguration()).addListener(
        ImageStreamListener(
            (ImageInfo info, bool _) => completer.complete(info.image)),
      );
    return completer.future;
    }
  2. we get out the size of the container and pass it on for further calculations
    
    // setup helpers in stateful widget
    static final _initialScale = PhotoViewComputedScale.contained * 1;
    static final _minScale = PhotoViewComputedScale.contained * 1;
    static final _maxScale = PhotoViewComputedScale.covered * 4;
    PageController _pageController = PageController();
    bool isScaled = true;
    void _spreadViewStateChanged(PhotoViewScaleState value) => setState(() {
        isScaled = value == PhotoViewScaleState.initial ||
            value == PhotoViewScaleState.covering;
      });

// now setup PhotoViewGallery in build Builder( builder: (_) => { final mediaQuery = MediaQuery.of(context); final size = mediaQuery.size; final widthPercentsPx = size.width / 100; return PhotoViewGallery.builder( scaleStateChangedCallback: spreadViewStateChanged, builder: (, index) => _buildItem( index, size: size, widthPercentsPx: widthPercentsPx, ), pageController: _pageController, itemCount: 100, ); } );

// build item method PhotoViewGalleryPageOptions _buildItem( int index, { Size size, double widthPercentsPx, }) => PhotoViewGalleryPageOptions.customChild( child: YouWidget(), childSize: Size( 400, 600, ), initialScale: _initialScale, minScale: _minScale, maxScale: _maxScale, onTapUp: ( BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue, ) { context.visitChildElements((element) { // Find image widget inside YouWidget() findChild(element, (imageElement) async { final RenderBox box = context.findRenderObject(); final imageProvider = (imageElement.widget as Image).image;

            // get image resolution
            final info = await getImageInfo(imageProvider);

            // magic working for images where the height prevails 
           // over the width, honestly forgot what is happening here,
           //  you can play around with the values
            final imageBoxWidth = widthPercentsPx *
                (((info.width / info.height) + 0.2) * 100);

            final imageSize = Size(imageBoxWidth, box.size.height);

            _handleImageTapUp(
              box,
              context,
              details,
              controllerValue,
              size,
              imageSize,
            );
          });
        });
      });

// tap handler

double _calculateLeftOffset( Size fullSizePercentUnits, Size visibleArea, Offset visiblePosition, ) => ((fullSizePercentUnits.width * 100 - visibleArea.width) / 2) - visiblePosition.dx;

double _calculateRightOffset( Size fullSizePercentUnits, Size visibleArea, Offset visiblePosition, ) => ((fullSizePercentUnits.height * 100 - visibleArea.height) / 2) - visiblePosition.dy;

void _handleImageTapUp( RenderBox box, BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue, Size size, Size imageSize, ) { final localPosition = box.globalToLocal(details.globalPosition); // ~1.375 - initial scale in [PhotoView] // 7.27 - unit multiplier final scale = (controllerValue.scale ?? 1.375) 7.27 0.1;

// Left & right padding between image and outside container
// ignore: omit_local_variable_types
final double horizontalOffset =
    isScaled ? (size.width - imageSize.width) / 2 : 0;

// Tapped outside image (on omage padding)
if (isScaled &&
    (localPosition.dx < horizontalOffset ||
        localPosition.dx > horizontalOffset + imageSize.width)) {
  return;
}

final fullWidthPercent = (imageSize.width / 100) * scale,
    fullHeightPercent = (imageSize.height / 100) * scale;
final fullSizePercentUnits = Size(fullWidthPercent, fullHeightPercent);

final leftOffset = _calculateLeftOffset( fullSizePercentUnits, visibleArea, visiblePosition, ), topOffset = _calculateRightOffset( fullSizePercentUnits, visibleArea, visiblePosition, );

// real image tap position final tapPositionX = position.dy + topOffset, tapPositionY = position.dx + leftOffset; }

Dev-Owl commented 4 years ago

Hi, First of all thanks for the reply, it came out to be more work as I thought but I came up with a solution that covers this request but only 50% of my final goal. Let's start with my solution:

Let's start with the required inputs, all except one are coming from the PhotoViewControllerValue provided in the update stream. They are mapped to a little utility class as shown below:

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

  Future<ImageInfo> _getImage() {
    final Completer completer = Completer<ImageInfo>();
    final ImageStream stream = widget.imageProvider.resolve(
      const ImageConfiguration(),
    );
    final listener = ImageStreamListener((
      ImageInfo info,
      bool synchronousCall,
    ) {
      if (!completer.isCompleted) {
        completer.complete(info);
        if (mounted) {
          final setupCallback = () {
            imageSize = Size(
              info.image.width.toDouble(),
              info.image.height.toDouble(),
            );
            _loading = false;
          };
          synchronousCall ? setupCallback() : setState(setupCallback);
        }
      }
    });
    stream.addListener(listener);
    completer.future.then((_) {
      stream.removeListener(listener);
    });
    return completer.future;
  }

Inside my FlatmapState the followin position maps the absolute point to draw an element to the current view port:

class FlatMapState {
  final Offset viewPortOffsetToCenter;
  final double imageScale;
  final Size initialViewPort;
  final Size imageSize;
  final Offset viewPortDelta;
  final Offset currentViewPort;

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

As I build some things are around I try to make it as easy as possible, the drawing part is done like this:

Widget _buildBody(BuildContext context) {
    if (_loading) {
      return Center(
        child: CircularProgressIndicator(),
      );
    } else {
      return Container(
        child: Center(
          child: CustomPaint(
            foregroundPainter: FlatMapPainter(
              drawableElements: hideMarker ? null : elementsToDraw,
              flatMapState: _currentState,
            ),
            child: PhotoView(
              key: stickyKey,
              imageProvider: imageProvider,
              tightMode: true,
              controller: _controller,
              onTapUp: (buildContext, tap, value) {
                if (mapTapped != null) {
                  mapTapped(FlatMapTappedEvent(
                      context: buildContext,
                      flatMapState: _currentState,
                      photoViewControllerValue: value,
                      tapUpDetails: tap,
                      tappedMarker: elementsToDraw
                          .where((drawable) => drawable.touched(
                              _currentState?.viewPortToAbsolutionPosition(
                                  tap.localPosition),
                              _currentState))
                          .toList()));
                }
              },
            ),
          ),
        ),
      );
    }
  }

The painter works like this:

class FlatMapPainter extends CustomPainter {
  final List<FlatMapDrawable> drawableElements;
  final FlatMapState flatMapState;

  FlatMapPainter(
      {@required this.flatMapState, @required this.drawableElements});

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

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true; //TODO evaluate what is the right way here
  }
}

I'm currently very limited with free time sorry for not providing a complete project but I hope it helps somebody.

renancaraujo commented 4 years ago

Sorry guys, I've been really, really busy those days preparing stuff for flutter europe. I see your question and I have an ideia to solve this problem with custom child, once it is ready I post it here.

Dev-Owl commented 4 years ago

@SergeShkurko I had a look at your solution and got some issues giving it a try. I had to change a few things like the findChild method (unable to find where this was comming from). Also I didn't found the origin of:

Could you maybe provide a little insight on this?

@renancaraujo Did you had any time / luck to get something in that direction ?

scopendo commented 4 years ago

@Dev-Owl – are you trying to keep the overlayed widget at the same size relative to the screen when scaling occurs, or to scale in size proportional to the PhotoView child? I'm trying to get the widget to remain the same size relative to the screen, as Google Maps markers do when scaling occurs, but haven't had any luck – I have managed to keep overlayed widgets in the same position and have them scale proportionally.

final size = Size(someWidth, someHeight);
PhotoView.customChild(
  child: CustomWidget(size: size),
  childSize: size,
)
class CustomWidget extends StatelessWidget {
  CustomWidget({this.size});

  final Size size;

  @override
  Widget build(BuildContext context) {
    return OverflowBox(
      maxWidth: size.width,
      maxHeight: size.height,
      child: Stack(
        children: <Widget>[
          Image.asset(
            "images/some-image.png",
            width: size.width,
            height: size.height,
          ),
          Positioned(
              top: size.height / 3,
              left: size.width / 3,
              child: Container(
                color: Colors.pink,
                width: 20,
                height: 20,
              ),
           ),
        ],
      ),
    );
  }
}
renancaraujo commented 4 years ago

@Dev-Owl Sorry for the delay.

Managed to work on this tonight and I identified some pretty nasty issues withing our tap callbacks.

It is really difficult to get a tap position regarding the image. So I had to wrap the image on another gesture detector wrapping the image only one custom child.

The code can be found here: https://github.com/renancaraujo/photo_view/blob/de9f12f42961f5154da316036715a66a972da1b9/example/lib/screens/examples/ontap_callbacks.dart (you can use something else to display the dots)

This gif shows the result: ezgif com-resize

renancaraujo commented 4 years ago

We shall change the internal gesture detector to avoid this nasty workaround.

Dev-Owl commented 4 years ago

Hi Guys,

Sorry busy over here, @scopendo actually my code would like to do the same as @renancaraujo beside that its not so smooth and doesn't support rotation. Big thanks to @renancaraujo I will definitely have a look at your code, for me the last thing I couldn't get to work was recognizing a tap on my drawn element/marker. It would be great if this could be build into the tool, I think it would open up some nice use cases. My case by the way is using it as a simple map (to display a layout of a vessel with all decks/rooms etc. ) to put hotspots on it and let user interact with it. For the web solution we use Leaflet unfortunately the leaflet implementation for Flutter has some issues with "simple" coordinates (plain pixel) and did not work for this use case.

Again, thanks for the response and effort from your side :)

renancaraujo commented 4 years ago

Looks like this got solved. I've created the related issues for the tap detection location. I shall close this issue for now. If you wanna retake the discussion, feel free to open it again.

Dev-Owl commented 4 years ago

Sorry, so many things pilled up the last weeks I just had the time to check this today. Your solution works. Just wondering how I get the absolute position of the tap (regarding the image)? Let's say you want to exchange the information with another solution (or get the data from 3rd party) I would get a list of positions in absolute points on the image, somehow this needs to be fitted to and from the view. Still looking at the code and trying to wrap my head around, any help is welcome

Dev-Owl commented 4 years ago

I found a way to do it, just in case somebody else needs this. The below code recalculates the position from/to absolute position based on the example provided from @renancaraujo (with a small change to get the max width). Please note that the below code is the complete view, just take the parts you need, yes its kind of messy as it's my development try-out project:

class _ComponentLocationState extends State<ComponentLocation> {
  File _loadedFile;
  PhotoViewControllerBase controller;
  double currentScale = 1;
  final double pinSize = 50;
  final GlobalKey _centerKey = GlobalKey();
  double maxWidth = 0;
  Size imageSize;

  @override
  void initState() {
    super.initState();
    controller = PhotoViewController()
      ..outputStateStream.listen(onControllerState);
  }

  void onControllerState(PhotoViewControllerValue value) {
    setState(() {
      currentScale = value.scale;
    });
  }

  Future<File> getLayoutPicture() async {
    if (_loadedFile != null) return _loadedFile;
    var dirToSave = await getApplicationDocumentsDirectory();
    var localFileName =
        File(dirToSave.path + "/layout_${globals.currentWorkingVessel.id}.jpg");
    if (!await localFileName.exists()) {
      return null;
    } else {
      painting.imageCache.evict(new FileImage(localFileName));
      painting.imageCache.evict(localFileName.path);

      _loadedFile = localFileName;
      var info = await _getImage();
      imageSize =
          Size(info.image.width.toDouble(), info.image.height.toDouble());
      return localFileName;
    }
  }

  Future<ImageInfo> _getImage() {
    final Completer completer = Completer<ImageInfo>();
    final ImageStream stream = Image.file(_loadedFile).image.resolve(
          const ImageConfiguration(),
        );
    final listener = ImageStreamListener((
      ImageInfo info,
      bool synchronousCall,
    ) {
      if (!completer.isCompleted) {
        completer.complete(info);
        if (mounted) {
          final setupCallback = () {
            imageSize = Size(
              info.image.width.toDouble(),
              info.image.height.toDouble(),
            );
          };
          synchronousCall ? setupCallback() : setState(setupCallback);
        }
      }
    });
    stream.addListener(listener);
    completer.future.then((_) {
      stream.removeListener(listener);
    });
    return completer.future;
  }

  Widget pointToWidget(double x, double y) {
    if (x == null || y == null) return null;
    var currentSize =
        (currentScale > 1 ? pinSize / currentScale : 25.0).roundToDouble();
    var scaledX = x / (imageSize.width / maxWidth);
    var scaledY = y / (imageSize.width / maxWidth);
    return Positioned(
        top: (scaledY - (currentSize / 1.5)).roundToDouble(),
        left: (scaledX - (currentSize / 2)).roundToDouble(),
        child: Icon(
          Icons.location_on,
          color: Colors.blue,
          size: currentSize,
        ));
  }

  void onTapUp(TapUpDetails details) {
    print(details.localPosition);
    widget.currentComponent.locationX = details.localPosition.dx * (imageSize.width / maxWidth);
    widget.currentComponent.locationY =details.localPosition.dy * (imageSize.width / maxWidth);
    print("X:${details.localPosition.dx * (imageSize.width / maxWidth)}");
    print("Y:${details.localPosition.dy * (imageSize.width / maxWidth)}");

    widget.currentComponent.saveToDb().then((_) {
      setState(() {});
    });
  }

  Widget _buildInner(BuildContext context) {
    if (_loadedFile == null) {
      return FutureBuilder<File>(
          future: getLayoutPicture(),
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.done) {
              if (snapshot.data == null) {
                return Center(child: Text("No layout picture found"));
              }
              return _buildGADrawing(snapshot.data);
            } else {
              return CircularProgressIndicator();
            }
          });
    } else {
      return _buildGADrawing(_loadedFile);
    }
  }

  Widget _buildGADrawing(File file) {
    var layoutChilds = <Widget>[
      Image.file(file),
    ];
    return Container(
      child: PhotoView.customChild(
        enableRotation: false,
        initialScale: 1.0,
        controller: controller,
        onTapUp: (context, tapUp, controllerVlaue) {
          print(tapUp);
        },
        child: LayoutBuilder(builder: (buildContext, boxConstraints) {
          maxWidth = boxConstraints.maxWidth;
          var currentPoistion = pointToWidget(widget.currentComponent.locationX,
              widget.currentComponent.locationY);
          if (currentPoistion != null) {
            layoutChilds.add(currentPoistion);
          }
          return Center(
            key: _centerKey,
            child: GestureDetector(
                onTapUp: onTapUp,
                child: Stack(
                  children: layoutChilds,
                )),
          );
        }),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Edit component location"),
      ),
      body: _buildInner(context),
    );
  }
}