TDLR
How can I setup a scrollable view (two-finger) with a draggable item (one-finger) in Flutter in way that the item doesn't eat up the two-finger gesture.
Goal
I am trying to setup an application in Flutter that behaves as follows:
- It provides a view that can be panned and zoomed with two finger gestures
- Inside view resides an item (e.g. box) that can be dragged with one finger
At the moment I am using GestureDetectors for that. I am aware of the GestureArena handling that Flutter internally does.
Problem
When panning the view with two fingers, the item interprets this as a drag operation once at least one finger touches the item. Technically spoken "onHorizontalDragUpdate" is called although I am using two fingers on the screen.
The GestureArea debug output states that the Drag-GestureDetector of the item wins:
Accepting: HorizontalDragGestureRecognizer#b044d(debugOwner: GestureDetector-[GlobalKey#ebeb1 Grüne Box], start behavior: start)
What I'd like to have is that the Scale-GestureDetector of the view wins.
What I would expect for that is that either the HorizontalDrag-GestureDetector on the item surrenders in the gesture arena once a second finger comes into play. Or that the Scale-GestureDetector on the view declares victory in the arena once two fingers are used.
Question
How can this use case be solved in Flutter?
- Is it possible to configure the Drag-GestureDetector to only accept one finger drags?
- Or is it possible to give the Scale-GestureDetector priority for two finger touches?
- Or is there any other solution?
This use case sounds so common that I probably just didn't find the answer yet, although it is probably there. Thus I really appreciate any help.
Example
The following video demonstrates the problem. The two finger pan should always pan the view and not scale the green box. The video is made in the Android emulator:
https://www.screencast.com/t/Y6nwpioFb
Here is the full source code of that sample:
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
void main() {
debugPrintGestureArenaDiagnostics = true;
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
double _scale = 1.0;
var _translation = const Offset(0, 0);
double _scaleStart = 1.0;
double _boxScale = 1.0;
final _color = Colors.green;
final _boxGesture = GlobalKey(debugLabel: "Grüne Box");
final _viewGesture = GlobalKey(debugLabel: "Blaue View");
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
if (details.delta.distance > 0) {
_boxScale = _boxScale + (details.delta.dx / 100);
}
});
}
Widget buildContent(BuildContext context) {
Matrix4 matrix = Matrix4.identity();
matrix.scale(_scale);
matrix.translate(_translation.dx, _translation.dy);
// Scalable item
Widget child = Transform(
transform: matrix,
alignment: Alignment.topLeft,
child: GestureDetector(
key: _boxGesture,
onHorizontalDragUpdate: _handleDragUpdate,
child: SizedBox(
width: 500 * _boxScale,
height: 500,
child: Card(
color: _color,
),
),
),
);
child = OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0.0,
minHeight: 0.0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: child,
);
child = Container(
color: Colors.blue,
child: ClipRect(
clipBehavior: Clip.hardEdge,
child: child,
),
);
// View pan/zoom handling
child = GestureDetector(
key: _viewGesture,
onScaleStart: (details) {
_scaleStart = _scale;
},
onScaleUpdate: (details) {
if (details.pointerCount == 2) {
setState(() {
_scale = _scaleStart * details.scale;
_translation += details.focalPointDelta;
});
}
},
onScaleEnd: (details) {},
child: child,
);
child = Center(
child: child,
);
return child;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: buildContent(context),
));
}
}
