Flutter tutorial: image magnifier
Tags: flutter, tutorial, dart, appdevelopment
Published: 2020-09-23 12:46:48

Especially when dragging markers across an image, it's quite common to show a magnifier that enlarges the area because it's covered by the finger. Let's have a look at how to implement that in Flutter.

![Our goal](https://dev-to-uploads.s3.amazonaws.com/i/wgd9c1r0nz4cw9hanojq.gif)

# Our goal

This above description is very broad. Let's go into further detail by defining the key features of our magnifier:

* There is a half-transparent circle than can be dragged across the image
* Once it's being dragged, the magnifier appears on the top left
* Whenever the circle interferes with the magnifier, it jumps to the top right
* The magnifier enlarges the area of the underlying image by a factor that is definable
* Once the drag ends, the magnifier disappears
* This does not only work for images but for every widget

# Implementation

We start by implementing a sample screen that contains the image we want to add the possibility to zoom on. For this, we use the most popular test image: [Lena Forsén, better known as Lenna](https://en.wikipedia.org/wiki/Lenna).

```dart
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Image(
          image: AssetImage('assets/lenna.png')
        )
      ],
    );
  }
```

We use a `Stack` widget because we want to put the touch bubble and the magnifier on top later on.

## The touch bubble

Now we need the touch bubble the user should be able to position on the part of the image that should be magnified.

```dart
class _SampleImageState extends State<SampleImage> {
  static const double touchBubbleSize = 20;

  Offset position;
  double currentBubbleSize;
  bool magnifierVisible = false;

  @override
  void initState() {
    currentBubbleSize = touchBubbleSize;
    SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Magnifier(
          position: position,
          visible: magnifierVisible,
          child: Image(
            image: AssetImage('assets/lenna.png')
          ),
        ),
        _getTouchBubble()
      ],
    );
  }

  Positioned _getTouchBubble() {
    return Positioned(
        top: position == null ? 0 : position.dy - currentBubbleSize / 2,
        left: position == null ? 0 : position.dx - currentBubbleSize / 2,
        child: GestureDetector(
          child: Container(
            width: currentBubbleSize,
            height: currentBubbleSize,
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: Theme.of(context).accentColor.withOpacity(0.5)
            ),
          )
        )
    );
  }
```

We need to store the position of the touch bubble so that we can provide that piece of information to the magnifier. This is necessary so that the magnifier can only enlarge the particular area that is determined by the touch bubble.

We also have a static size of the touch bubble which determines the base size the bubble returns to when no interaction is happening. We also need the actual current size that can differ when the user drags the bubble. To highlight the drag event we want the bubble to grow by 50 %. 

We could implement it like above and just extract the visual description of the touch bubble into its own method which resides in the sample image widget. However, it's a good idea to [put it into its own widget](https://www.flutterclutter.dev/flutter/basics/reusable-widgets-functions-or-classes/2020/138/).

This will make it look like this:

```dart
import 'package:flutter/material.dart';

class TouchBubble extends StatelessWidget {
  TouchBubble({
    @required this.position,
    this.bubbleSize
  });

  final Offset position;
  final double bubbleSize;

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: position == null ? 0 : position.dy - bubbleSize / 2,
      left: position == null ? 0 : position.dx - bubbleSize / 2,
      child: GestureDetector(
        child: Container(
          width: bubbleSize,
          height: bubbleSize,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Theme.of(context).accentColor.withOpacity(0.5)
          ),
        )
      )
    );
  }
}
```

So far so good. But yet, there is no interaction possible:

![Iteration 1](https://dev-to-uploads.s3.amazonaws.com/i/rprh0mo0y9kgz5hr5izb.jpg)

The touch bubble is visible at the top left because that is the fallback value for the top and left property of the `Positioned` widget inside our `TouchBubble` class.

In order to add interactivity to our magnifier, we let the `TouchBubble` expect some callbacks in its constructor:

```dart
import 'package:flutter/material.dart';

class TouchBubble extends StatelessWidget {
  TouchBubble({
    this.position,
    @required this.onStartDragging,
    @required this.onDrag,
    @required this.onEndDragging,
    @required this.bubbleSize,
  }) : assert(onStartDragging != null),
       assert(onDrag != null),
       assert(onEndDragging != null),
       assert(bubbleSize != null && bubbleSize > 0);

  final Offset position;
  final double bubbleSize;
  final Function onStartDragging;
  final Function onDrag;
  final Function onEndDragging;

  @override
  Widget build(BuildContext context) {
    return Positioned(
      ...
      child: GestureDetector(
        onPanStart: (details) => onStartDragging(details.globalPosition),
        onPanUpdate: (details) => onDrag(details.globalPosition),
        onPanEnd: (_) => onEndDragging(),
        ...
      )
    );
  }
}
```

Now we have three new additional arguments: `onStartDragging`, `onDrag` and `onEndDragging`. The behavior that is triggered by these functions, will be defined in the parent widget. We annotate all of them (expect for position because we have a fallback) as @required which tells our IDE to warn us if we omit this argument. Additionally, we provide asserts for runtime errors to occur at the correct place if we ignore the suggestions of our IDE.

`onStartDragging` and `onDrag` are expected to be provided with the new absolute (global) position. `onEndDragging` does not expect any argument.


Our new `SampleImage` widget looks like this:

```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_magnifier/touch_bubble.dart';

class SampleImage extends StatefulWidget {
  @override
  _SampleImageState createState() => _SampleImageState();
}

class _SampleImageState extends State<SampleImage> {
  static const double touchBubbleSize = 20;

  Offset position;
  double currentBubbleSize;
  bool magnifierVisible = false;

  @override
  void initState() {
    currentBubbleSize = touchBubbleSize;
    SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Image(
          image: AssetImage('assets/lenna.png')
        ),
        TouchBubble(
          position: position,
          bubbleSize: currentBubbleSize,
          onStartDragging: _startDragging,
          onDrag: _drag,
          onEndDragging: _endDragging,
        )
      ],
    );
  }

  void _startDragging(Offset newPosition) {
    setState(() {
      magnifierVisible = true;
      position = newPosition;
      currentBubbleSize = touchBubbleSize * 1.5;
    });
  }

  void _drag(Offset newPosition) {
    setState(() {
      position = newPosition;
    });
  }

  void _endDragging() {
    setState(() {
      currentBubbleSize = touchBubbleSize;
      magnifierVisible = false;
    });
  }
}
```

The position is updated on drag start and on drag update. When the dragging starts, the mag is made visible and increases its size by 50 %. When dragging has ended, the size is turned back to normal and the magnifier is turned invisible.

It leads to a UI that behaves like this:

![The moving touch bubble](https://dev-to-uploads.s3.amazonaws.com/i/giriercp85psfam66l55.gif)

## The magnifier

Before we jump into the implementation, let's think for a second, what magnifying actually means. We take an image, scale it up, move the image to where the center of the mag is and then crop it so that only the inner part of the mag shows the translated image.

The translation is also influenced by the scale factor. Visually this can be explained like this:

![There is scaling and translation involved](https://dev-to-uploads.s3.amazonaws.com/i/e8ghtnlvles6d922je9l.png)

Let's first create a new widget that represents our magnifier:

```dart
class Magnifier extends StatefulWidget {
  const Magnifier({
    @required this.child,
    @required this.position,
    this.visible = true,
    this.scale = 1.5,
    this.size = const Size(160, 160)
  }) : assert(child != null);

  final Widget child;
  final Offset position;
  final bool visible;
  final double scale;
  final Size size;

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

class _MagnifierState extends State<Magnifier> {
  Size _magnifierSize;
  double _scale;
  Matrix4 _matrix;

  @override
  void initState() {
    _magnifierSize = widget.size;
    _scale = widget.scale;
    _calculateMatrix();

    super.initState();
  }

  @override
  void didUpdateWidget(Magnifier oldWidget) {
    super.didUpdateWidget(oldWidget);

    _calculateMatrix();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        widget.child,
        if (widget.visible && widget.position != null)
          _getMagnifier(context)
      ],
    );
  }

  void _calculateMatrix() {
    if (widget.position == null) {
      return;
    }

    setState(() {
      double newX = widget.position.dx - (_magnifierSize.width / 2 / _scale);
      double newY = widget.position.dy - (_magnifierSize.height / 2 / _scale);

      final Matrix4 updatedMatrix = Matrix4.identity()
        ..scale(_scale, _scale)
        ..translate(-newX, -newY);

      _matrix = updatedMatrix;
    });
  }
}
```

We have a few constructor arguments here. The most relevant are `child` and `position`. Because we want this feature to work with every widget, we need this widget to be provided (child parameter). In order to determine which part of the underlying widget should be magnified, we also let it require a `position` parameter.

`visible` is used to only display it when the user drags the bubble. `scale` and `size` should be self-explanatory and have a default value.

The crucial business logic here is the `_calculateMatrix()` method because it determines scaling and translation based on the position of the touch bubble. We take the identity matrix and use the scale and translation method to update it. Important: we need to use the center of the touch bubble as the anchor. That's why we subtract half of the height and the width to determine the position.

Okay, now the part of drawing the actual magnifier is still missing. We call `_getMagnifier()` but yet, there is no implementation.

```dart
Widget _getMagnifier(BuildContext context) {
  return Align(
    alignment: Alignment.topLeft,
    child: ClipOval(
      child: BackdropFilter(
        filter: ImageFilter.matrix(_matrix.storage),
        child: Container(
            width: _magnifierSize.width,
            height: _magnifierSize.height,
            decoration: BoxDecoration(
              color: Colors.transparent,
              shape: BoxShape.circle,
              border: Border.all(color: Colors.blue, width: 2)
        ),
      ),
    ),
  );
}
```

This deserves some explanation. Now, the crucial widget here is [BackdropFilter](https://api.flutter.dev/flutter/widgets/BackdropFilter-class.html). This filter takes a filter and a child. It applies the given filter to the child. The filter we use is matrix. This lets us apply the calculated matrix to the child. It's very important to wrap it in a [ClipOval](https://api.flutter.dev/flutter/widgets/ClipOval-class.html) because we only want a sized circle to be seen and not the whole scaled and translated duplication of our original image.

In order to improve the possibility to place the bubble exactly where we want to, we can add a crosshair to the magnifier. This requires us to replace the Container by a self-drawn widget.

```dart
import 'package:flutter/material.dart';

class MagnifierPainter extends CustomPainter {
  const MagnifierPainter({
    @required this.color,
    this.strokeWidth = 5
  });

  final double strokeWidth;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    _drawCircle(canvas, size);
    _drawCrosshair(canvas, size);
  }

  void _drawCircle(Canvas canvas, Size size) {
    Paint paintObject = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..color = color;

    canvas.drawCircle(
      size.center(
        Offset(0, 0)
      ),
      size.longestSide / 2, paintObject
    );
  }

  void _drawCrosshair(Canvas canvas, Size size) {
    Paint crossPaint = Paint()
      ..strokeWidth = strokeWidth / 2
      ..color = color;

    double crossSize = size.longestSide * 0.04;

    canvas.drawLine(
      size.center(Offset(-crossSize, -crossSize)),
      size.center(Offset(crossSize, crossSize)),
      crossPaint
    );

    canvas.drawLine(
      size.center(Offset(crossSize, -crossSize)),
      size.center(Offset(-crossSize, crossSize)),
      crossPaint
    );
  }

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

We start by drawing a circle of the given size but in stroke mode so we only draw the outline. After that, we draw the cross by using the center and shifting it by 4 % of the magnifier size to the top left and bottom right.

Now the `_getMagnifier()` method in the `Magnifier` class looks like this:

```dart
  Widget _getMagnifier(BuildContext context) {
    return Align(
      alignment: Alignment.topLeft,
      child: ClipOval(
        child: BackdropFilter(
          filter: ImageFilter.matrix(_matrix.storage),
          child: CustomPaint(
            painter: MagnifierPainter(
              color: Theme.of(context).accentColor
            ),
            size: _magnifierSize,
          ),
        ),
      ),
    );
  }
```

What we want to take care of now is that the magnifier jumps to the top right when our touch bubble's position interferes with the magnifier. 

```dart
  Alignment _getAlignment() {
    if (_bubbleCrossesMagnifier()) {
      return Alignment.topRight;
    }

    return Alignment.topLeft;
  }

  bool _bubbleCrossesMagnifier() => widget.position.dx < widget.size.width &&
    widget.position.dy < widget.size.height;
```

Instead of assigning `Alignment.topLeft` statically to the magnifier, we decide whether to use `topLeft` or `topRight` based on its position.

Now we need to add an extra block to the `calculateMatrix()` method:

```dart
if (_bubbleCrossesMagnifier()) {
  final box = context.findRenderObject() as RenderBox;
  newX -= ((box.size.width - _magnifierSize.width) / _scale);
}
```

Whenever the conditions are met, we influence the matrix translation by shifting the `newX` by the whole width subtracted by the mag width because that is exactly the amount by which the magnifier moves when the alignment is changed from left to right.

# Closing comment

Using the expensive but handy Widget `BackdropFilter` and a little bit of matrix calculation, we were able to implement a magnifier that is able to enlarge every child widget given a position. The magnifier is located at the top left but jumps out of the way when our touch collides with the position of the magnifier.

You can see it in action (and see the code) by having a look at [my edge detection project](https://github.com/flutter-clutter/flutter-simple-edge-detection). The magnifier is used on touch bubbles that mimic the detected edges around a dynamic image.