Flutter make image with GestureDetectors also Draggable

1,724

The problem you have is a conflict between:

  • zooming and dragging the image inside the custom ClipPath
  • dragging the images between two custom ClipPath

The solution I propose is to use drag handles to swap the images

!!! SPOILER : It does not work (yet) !!!

To implement this drag-n-drop with custom ClipPath, we need the support of HitTestBehavior.deferToChild on DragTarget.

The good news is... It's already available in Flutter master channel! [ref]

So, if you can wait a bit for it to be released in stable, here is my solution:

enter image description here

The main idea is to have the zoomable images as DragTargets and for each image a drag handle as Draggable.

I added a layer of State Management to keep the zoom level and offset when swapping the images.

I also improved the zoomable feature to ensure that the image always covers the full ClipPath.

Full source code (250 lines)

import 'dart:math' show min, max;

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

part '66474773.drag.freezed.dart';

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        debugShowCheckedModeBanner: false,
        title: 'Flutter Demo',
        home: HomePage(),
      ),
    ),
  );
}

class HomePage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final images = useProvider(imagesProvider.state);
    final _width = MediaQuery.of(context).size.shortestSide * .8;

    void swapImages() => context.read(imagesProvider).swap();

    return Scaffold(
      backgroundColor: Colors.black87,
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Container(
            height: _width,
            width: _width,
            child: Stack(
              children: [
                DragTarget<VerticalDirection>(
                  hitTestBehavior: HitTestBehavior.deferToChild,
                  onWillAccept: (direction) =>
                      direction == VerticalDirection.up,
                  onAccept: (_) => swapImages(),
                  builder: (_, __, ___) => _Zoomable(
                    key: GlobalKey(),
                    width: _width,
                    pathFn: topPathFn,
                    imageId: 0,
                  ),
                ),
                DragTarget<VerticalDirection>(
                  hitTestBehavior: HitTestBehavior.deferToChild,
                  onWillAccept: (direction) =>
                      direction == VerticalDirection.down,
                  onAccept: (_) => swapImages(),
                  builder: (_, __, ___) => _Zoomable(
                    key: GlobalKey(),
                    width: _width,
                    pathFn: bottomPathFn,
                    imageId: 1,
                  ),
                ),
                Positioned.fill(
                  child: Align(
                    alignment: Alignment.topLeft,
                    child: _DragHandle(
                      direction: VerticalDirection.down,
                      imgAssetPath: images[0].assetPath,
                    ),
                  ),
                ),
                Positioned.fill(
                  child: Align(
                    alignment: Alignment.bottomRight,
                    child: _DragHandle(
                      direction: VerticalDirection.up,
                      imgAssetPath: images[1].assetPath,
                    ),
                  ),
                ),
              ],
            )),
      ),
    );
  }
}

class _DragHandle extends StatelessWidget {
  final VerticalDirection direction;
  final String imgAssetPath;

  const _DragHandle({Key key, this.direction, this.imgAssetPath})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Draggable<VerticalDirection>(
      data: direction,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.grey.shade200,
          border: Border.all(color: Colors.grey.shade700),
        ),
        child: Icon(Icons.open_with),
      ),
      childWhenDragging: Container(),
      feedback: Image.asset(imgAssetPath, width: 80),
    );
  }
}

class _Zoomable extends HookWidget {
  final double width;
  final Path Function(Size) pathFn;
  final int imageId;

  const _Zoomable({
    Key key,
    this.width,
    this.pathFn,
    this.imageId,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final image =
        useProvider(imagesProvider.state.select((state) => state[imageId]));
    final _startingFocalPoint = useState(Offset.zero);
    final _previousOffset = useState<Offset>(null);
    final _offset = useState(image.offset);
    final _previousZoom = useState<double>(null);
    final _zoom = useState(image.zoom);
    return CustomPaint(
      painter: MyPainter(pathFn: pathFn),
      child: GestureDetector(
        onTap: () {}, // onScaleUpdate not triggered if onTap is not defined
        onScaleStart: (details) {
          _startingFocalPoint.value = details.focalPoint;
          _previousOffset.value = _offset.value;
          _previousZoom.value = _zoom.value;
        },
        onScaleUpdate: (details) {
          _zoom.value = max(1, _previousZoom.value * details.scale);
          final newOffset = details.focalPoint -
              (_startingFocalPoint.value - _previousOffset.value) *
                  details.scale;
          _offset.value = Offset(
            min(0, max(-width * (_zoom.value - 1), newOffset.dx)),
            min(0, max(-width * (_zoom.value - 1), newOffset.dy)),
          );
        },
        onScaleEnd: (_) => context.read(imagesProvider).update(
            imageId, image.copyWith(zoom: _zoom.value, offset: _offset.value)),
        child: ClipPath(
          clipper: MyClipper(pathFn: pathFn),
          child: Transform(
            transform: Matrix4.identity()
              ..translate(_offset.value.dx, _offset.value.dy)
              ..scale(_zoom.value),
            child: Image.asset(
              image.assetPath,
              width: width,
              height: width,
              fit: BoxFit.fill,
            ),
          ),
        ),
      ),
    );
  }
}

Path bottomPathFn(Size size) => Path()
  ..moveTo(size.width, 0)
  ..lineTo(0, size.height)
  ..lineTo(size.height, size.height)
  ..close();

Path topPathFn(Size size) => Path()
  ..moveTo(size.width, 0)
  ..lineTo(0, size.height)
  ..lineTo(0, 0)
  ..close();

class MyClipper extends CustomClipper<Path> {
  final Path Function(Size) pathFn;

  MyClipper({this.pathFn});

  @override
  getClip(Size size) => pathFn(size);

  @override
  bool shouldReclip(CustomClipper oldClipper) {
    return false;
  }
}

class MyPainter extends CustomPainter {
  final Path Function(Size) pathFn;

  Path _path;

  MyPainter({this.pathFn});

  @override
  void paint(Canvas canvas, Size size) {
    _path = pathFn(size);
    final paint = Paint()
      ..color = Colors.white
      ..strokeWidth = 4.0
      ..style = PaintingStyle.stroke;
    canvas.drawPath(_path, paint);
  }

  @override
  bool hitTest(Offset position) {
    return _path?.contains(position);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

final imagesProvider =
    StateNotifierProvider<ImagesNotifier>((ref) => ImagesNotifier([
          ZoomedImage(assetPath: 'images/abstract.jpg'),
          ZoomedImage(assetPath: 'images/abstract2.jpg'),
        ]));

class ImagesNotifier extends StateNotifier<List<ZoomedImage>> {
  ImagesNotifier(List<ZoomedImage> state) : super(state);

  void swap() {
    state = state.reversed.toList();
  }

  void update(int id, ZoomedImage updatedImage) {
    state = [...state]..[id] = updatedImage;
  }
}

@freezed
abstract class ZoomedImage with _$ZoomedImage {
  const factory ZoomedImage({
    String assetPath,
    @Default(1.0) double zoom,
    @Default(Offset.zero) Offset offset,
  }) = _ZoomedImage;
}
Share:
1,724
Chris
Author by

Chris

Updated on December 28, 2022

Comments

  • Chris
    Chris over 1 year

    My goal is to have an image that I can zoom & move around inside a CustomClipperImage and it should also be Draggable!

    Right now I can scale the image in its Clip and this looks like this:

    Screenvideo

    This is the code for it:

              child: Container(
                  height: _containetWidth,
                  width: _containetWidth,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(10.0),
                    border: Border.all(color: Colors.white, width: 5),
                  ),
                  child: GestureDetector(
                    onTap: () => print("tapped"),
                    onScaleStart: (details) {
                      _startingFocalPoint.value = details.focalPoint;
                      _previousOffset.value = _offset.value;
                      _previousZoom.value = _zoom.value;
                    },
                    onScaleUpdate: (details) {
                      _zoom.value = _previousZoom.value * details.scale;
                      final Offset normalizedOffset =
                          (_startingFocalPoint.value - _previousOffset.value) /
                              _previousZoom.value;
                      _offset.value =
                          details.focalPoint - normalizedOffset * _zoom.value;
                    },
                    child: Stack(
                      children: [
                        ClipPath(
                          clipper: CustomClipperImage(),
                          child: Transform(
                            transform: Matrix4.identity()
                              ..translate(_offset.value.dx, _offset.value.dy)
                              ..scale(_zoom.value),
                            child: Image.asset('assets/images/example.jpg',
                                width: _containetWidth,
                                height: _containetWidth,
                                fit: BoxFit.fill),
                          ),
                        ),
                        CustomPaint(
                          painter: MyPainter(),
                          child: Container(
                              width: _containetWidth, height: _containetWidth),
                        ),
                      ],
                    ),
                  ),
                ),
    

    But I can not make it Draggable... I tried wrapping the whole Container or also just the Image.asset inside Draggable but when doing this, scaling stops working and Draggable is not working either.

    What is the best way to achieve this? I couldn't find anything on this... Let me know if you need more details!