Background
Trying to setup a simple image editor, allowing users to scale and move images around with gestures.
Scaling and moving works well via GestureDetector
, Transform.scale
and Transform.rotate
.
Problem
Upon scaling, the user can still scale the already scaled images.
But: The GestureDetector
does not change the area for performing hit tests.
Problem: The user can use the original hitbox only for manipulating images. It is not possible to scale the image by using the two-finger pinching gesture on the extended, outer shape.
Images
The first image demonstrates the basic setup.
The second image demonstrates the the result of using a gesture. It shows the small, unchanged inner hitbox. As well as the the resulting scaled and rotated shape.
The filled box is the hitbox. The outer rectangle shows the scaled image.
Desired Behavior
Using the two-finger pinching gesture on the scaled, outer shape should allow further manipulation of the object.
Instead, the inner hit box can be used alone. But a user expects to use the scaled, outer shape for further scaling and moving the object.
Code
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: GestureTest(),
);
}
}
class DrawContainer {
Color color;
Offset offset;
double scale;
double angle;
late double baseScaleFactor;
DrawContainer(this.color, this.offset, this.scale, this.angle) {
baseScaleFactor = scale;
}
onScaleStart() => baseScaleFactor = scale;
onScaleUpdate(double scaleNew) =>
scale = (baseScaleFactor * scaleNew).clamp(0.5, 5);
}
class GestureTest extends StatefulWidget {
const GestureTest({Key? key}) : super(key: key);
@override
// ignore: library_private_types_in_public_api
_GestureTestState createState() => _GestureTestState();
}
class _GestureTestState extends State<GestureTest> {
bool doRedraw = false;
final List<DrawContainer> containers = [
DrawContainer(Colors.red, const Offset(50, 50), 1.0, 0.0),
DrawContainer(Colors.yellow, const Offset(100, 100), 1.0, 0.0),
DrawContainer(Colors.green, const Offset(150, 150), 1.0, 0.0),
];
void onGestureStart(DrawContainer e) => e.onScaleStart();
onGestureUpdate(DrawContainer e, ScaleUpdateDetails d) {
e.offset = e.offset + d.focalPointDelta;
if (d.rotation != 0.0) e.angle = d.rotation;
if (d.scale != 1.0) e.onScaleUpdate(d.scale);
setState(() => doRedraw = !doRedraw); // redraw
}
void rebuildAllChildren(BuildContext context) {
void rebuild(Element el) {
el.markNeedsBuild();
el.visitChildren(rebuild);
}
(context as Element).visitChildren(rebuild);
}
@override
Widget build(BuildContext context) {
rebuildAllChildren(context);
return SafeArea(
child: Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
doRedraw ? const SizedBox.shrink() : const SizedBox.shrink(),
...containers.map((e) {
return Positioned(
top: e.offset.dy,
left: e.offset.dx,
child: Container(
color: e.color,
child: GestureDetector(
onScaleStart: (details) {
if (details.pointerCount == 2) {
onGestureStart(e);
}
},
onScaleUpdate: (details) => onGestureUpdate(e, details),
child: Transform.rotate(
angle: e.angle,
child: Transform.scale(
scale: e.scale,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: e.color)),
width: 100,
height: 100),
// Text(e.label, style: const TextStyle(fontSize: 40)),
))),
// ),
));
}).toList(),
],
),
));
}
}
Below is a generic, working example with the hitbox' size matching the scaled widget's size.
The basic structure is as follows:
SizedBox (infinite size) # may not be needed
- Stack
- GestureDetector for each Widget
- Stack
- Positioned, Transform
- Widget
import 'package:flutter/material.dart';
// -------------------------------------------------------------------
// THE ITEM TO BE DRAWN
// -------------------------------------------------------------------
class DrawContainer {
Color color;
Offset offset;
double width;
double height;
double scale;
double angle;
late double _baseScaleFactor;
late double _baseAngleFactor;
DrawContainer(this.color, this.offset, this.width, this.height, this.scale,
this.angle) {
onScaleStart();
}
onScaleStart() {
_baseScaleFactor = scale;
_baseAngleFactor = angle;
}
onScaleUpdate(double scaleNew) =>
scale = (_baseScaleFactor * scaleNew).clamp(0.5, 5);
onRotateUpdate(double angleNew) => angle = _baseAngleFactor + angleNew;
}
// -------------------------------------------------------------------
// APP
// -------------------------------------------------------------------
void main() {
runApp(const MaterialApp(home: GestureTest()));
}
class GestureTest extends StatefulWidget {
const GestureTest({Key? key}) : super(key: key);
@override
// ignore: library_private_types_in_public_api
_GestureTestState createState() => _GestureTestState();
}
// -------------------------------------------------------------------
// APP STATE
// -------------------------------------------------------------------
class _GestureTestState extends State<GestureTest> {
final List<DrawContainer> containers = [
DrawContainer(Colors.red, const Offset(50, 50), 100, 100, 1.0, 0.0),
DrawContainer(Colors.yellow, const Offset(100, 100), 200, 100, 1.0, 0.0),
DrawContainer(Colors.green, const Offset(150, 150), 50, 100, 1.0, 0.0),
];
void onGestureStart(DrawContainer e) => e.onScaleStart();
onGestureUpdate(DrawContainer e, ScaleUpdateDetails d) {
e.offset = e.offset + d.focalPointDelta;
if (d.rotation != 0.0) e.onRotateUpdate(d.rotation);
if (d.scale != 1.0) e.onScaleUpdate(d.scale);
setState(() {}); // redraw
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
body: SizedBox(
height: double.infinity,
width: double.infinity,
child: Stack(
children: [
...containers.map((e) {
return GestureDetector(
onScaleStart: (details) {
// detect two fingers to reset internal factors
if (details.pointerCount == 2) {
onGestureStart(e);
}
},
onScaleUpdate: (details) => onGestureUpdate(e, details),
child: DrawWidget(e));
}).toList(),
],
),
),
));
}
}
// -------------------------------------------------------------------
// POSITION, ROTATE AND SCALE THE WIDGET
// -------------------------------------------------------------------
class DrawWidget extends StatelessWidget {
final DrawContainer e;
const DrawWidget(this.e, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: e.offset.dx,
top: e.offset.dy,
child: Transform.rotate(
angle: e.angle,
child: Transform.scale(
scale: e.scale,
child: Container(
height: e.width,
width: e.height,
color: e.color,
),
),
),
),
],
);
}
}
This test case has been helpful: https://stackoverflow.com/a/68360447/12098106