Flutter make image with GestureDetectors also Draggable
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:
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;
}
Chris
Updated on December 28, 2022Comments
-
Chris over 1 year
My goal is to have an
image
that I can zoom & move around inside aCustomClipperImage
and it should also beDraggable
!Right now I can
scale
the image in itsClip
and this looks like this: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 wholeContainer
or also just theImage.asset
insideDraggable
but when doing this,scaling
stops working andDraggable
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!
-
Chris about 3 yearsLet us continue this discussion in chat.
-