-
Notifications
You must be signed in to change notification settings - Fork 28.7k
add minTaps argument to BaseTapAndDragGestureRecognizer and TapAndPanGestureRecognizer #164922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
After some testing, I see that you can't combine Full exampleimport 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Flutter code sample for [TapAndPanGestureRecognizer].
void main() {
runApp(const TapAndDragToZoomApp());
}
class TapAndDragToZoomApp extends StatelessWidget {
const TapAndDragToZoomApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: Center(child: TapAndDragToZoomWidget(child: MyBoxWidget()))),
);
}
}
class MyBoxWidget extends StatelessWidget {
const MyBoxWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(color: Colors.blueAccent, height: 100.0, width: 100.0);
}
}
// This widget will scale its child up when it detects a drag up, after a
// double tap/click. It will scale the widget down when it detects a drag down,
// after a double tap. Dragging down and then up after a double tap/click will
// zoom the child in/out. The scale of the child will be reset when the drag ends.
class TapAndDragToZoomWidget extends StatefulWidget {
const TapAndDragToZoomWidget({super.key, required this.child});
final Widget child;
@override
State<TapAndDragToZoomWidget> createState() => _TapAndDragToZoomWidgetState();
}
class _TapAndDragToZoomWidgetState extends State<TapAndDragToZoomWidget> {
final double scaleMultiplier = -0.0001;
double _currentScale = 1.0;
Offset? _previousDragPosition;
static double _keepScaleWithinBounds(double scale) {
const double minScale = 0.1;
const double maxScale = 30;
if (scale <= 0) {
return minScale;
}
if (scale >= 30) {
return maxScale;
}
return scale;
}
void _zoomLogic(Offset currentDragPosition) {
final double dx = (_previousDragPosition!.dx - currentDragPosition.dx).abs();
final double dy = (_previousDragPosition!.dy - currentDragPosition.dy).abs();
if (dx > dy) {
// Ignore horizontal drags.
_previousDragPosition = currentDragPosition;
return;
}
if (currentDragPosition.dy < _previousDragPosition!.dy) {
// Zoom out on drag up.
setState(() {
_currentScale += currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
} else {
// Zoom in on drag down.
setState(() {
_currentScale -= currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
}
_previousDragPosition = currentDragPosition;
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
TapAndPanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(
//minTaps: 2,
),
(TapAndPanGestureRecognizer instance) {
instance
..onTapDown = (TapDragDownDetails details) {
_previousDragPosition = details.globalPosition;
}
..onTapUp = (TapDragUpDetails details) {
//print("Tap Up");
}
..onDragStart = (TapDragStartDetails details) {
print("Drag Start");
_zoomLogic(details.globalPosition);
}
..onDragUpdate = (TapDragUpdateDetails details) {
print("Drag Update");
_zoomLogic(details.globalPosition);
}
..onDragEnd = (TapDragEndDetails details) {
print("Drag End");
setState(() {
_currentScale = 1.0;
});
_previousDragPosition = null;
};
},
),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(),
(DoubleTapGestureRecognizer instance) {
instance
..onDoubleTap = () {
print('Double Tap');
};
},
),
},
child: Transform.scale(scale: _currentScale, child: widget.child),
);
}
}
I wonder if it would make sense to add a clean way to recognize double or n taps within User taps three times then drags: You could interpret this in many ways:
Google earth does the first and Google photos does the second. With the current implementation, there is no way to let the third tap fall through back to the gesture arena as the beginning of a new gesture (such as a drag). Besides this, the current method of detecting a double tap with Clickint _tapUpCount = 0;
..onTapUp = (TapDragUpDetails details) {
_tapUpCount++;
if(_tapUpCount == 2){
performDoubleTap();
}
}
..onDragStart = (TapDragStartDetails details) {
//don't scale if a double tap occurred.
if(_tapUpCount <2){
_zoomLogic(details.globalPosition);
}
}
..onDragUpdate = (TapDragUpdateDetails details) {
//don't scale if a double tap occurred.
if(_tapUpCount <2){
_zoomLogic(details.globalPosition);
}
}
..onDragEnd = (TapDragEndDetails details) {
//don't scale if a double tap occurred.
if(_tapUpCount <2){
//...
}
};
It would be nice if there was a way to say "After n taps up, complete the gesture and fire the n-tapsUp callback, something like: (TapAndPanGestureRecognizer instance) {
instance
..nTaps = 2
..onNTaps(){
performDoubleTap();
} After All of this doesn't need to be part of the PR of course, I only mention this because in light of these issues, maybe my own solution is not good either and should be part of some entirely new gesture recognizer specifically meant for double tap, double-tap zoom and scale all in one. To be clean I do think the current PR is useful and would allow for replicating the Google photos behavior and even the Google earth behavior if you are able to perform a drag from the TapDragUpdate, eg. via a |
@Renzo-Olivares have you had a chance to look at the PR? I'd like to know if you think this is the right approach. |
Hi @yakagami, thank you for your patience will take a look this week. |
Hi @yakagami, thank you for your patience. I'm trying to understand the use-cases that this PR is trying to accomplish.
I think I was able to accomplish this case with the code below. Code Sampleimport 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const GooglePhotosTapAndScaleDemo(),
);
}
}
class GooglePhotosTapAndScaleDemo extends StatefulWidget {
const GooglePhotosTapAndScaleDemo({super.key});
@override
State<GooglePhotosTapAndScaleDemo> createState() =>
_GooglePhotosTapAndScaleDemoState();
}
class _GooglePhotosTapAndScaleDemoState
extends State<GooglePhotosTapAndScaleDemo> {
final Map<Type, GestureRecognizerFactory<GestureRecognizer>>
_gestureRecognizers = <Type, GestureRecognizerFactory<GestureRecognizer>>{};
final double scaleMultiplier = -0.0001;
double _currentScale = 1.0;
Offset? _previousDragPosition;
static double _keepScaleWithinBounds(double scale) {
const double minScale = 1.0;
const double maxScale = 30;
if (scale <= 0) {
return minScale;
}
if (scale >= 30) {
return maxScale;
}
return scale;
}
void _zoomLogic(Offset currentDragPosition) {
final double dx = (_previousDragPosition!.dx - currentDragPosition.dx)
.abs();
final double dy = (_previousDragPosition!.dy - currentDragPosition.dy)
.abs();
if (dx > dy) {
// Ignore horizontal drags.
_previousDragPosition = currentDragPosition;
return;
}
if (currentDragPosition.dy < _previousDragPosition!.dy) {
// Zoom out on drag up.
setState(() {
_currentScale += currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
} else {
// Zoom in on drag down.
setState(() {
_currentScale -= currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
}
_previousDragPosition = currentDragPosition;
}
void _initGestures() {
_gestureRecognizers[TapAndPanGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner: this),
(TapAndPanGestureRecognizer instance) {
instance
..dragStartBehavior = DragStartBehavior.down
..maxConsecutiveTap = 3
..onTapDown = (TapDragDownDetails details) {
_previousDragPosition = details.globalPosition;
}
..onTapUp = (TapDragUpDetails details) {
if (details.consecutiveTapCount == 2) {
setState(() {
if (_currentScale == 1.0) {
_currentScale = 2.0;
} else {
_currentScale = 1.0;
}
});
}
}
..onDragUpdate = (TapDragUpdateDetails details) {
if (details.consecutiveTapCount == 3) {
_zoomLogic(details.globalPosition);
}
};
},
);
}
@override
void initState() {
super.initState();
_initGestures();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scale Gesture Example')),
body: Center(
child: RawGestureDetector(
gestures: _gestureRecognizers,
child: Transform.scale(
scale: _currentScale,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.lightBlueAccent,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(
alpha: (255.0 * 0.5).roundToDouble(),
),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: const Center(child: FlutterLogo(size: 80)),
),
),
),
),
);
}
} In google photos on mobile I notice a few behaviors:
The code below accomplishes that for the most part. Code Sampleimport 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const GooglePhotosTapAndScaleDemo(),
);
}
}
class GooglePhotosTapAndScaleDemo extends StatefulWidget {
const GooglePhotosTapAndScaleDemo({super.key});
@override
State<GooglePhotosTapAndScaleDemo> createState() =>
_GooglePhotosTapAndScaleDemoState();
}
class _GooglePhotosTapAndScaleDemoState
extends State<GooglePhotosTapAndScaleDemo> {
final Map<Type, GestureRecognizerFactory<GestureRecognizer>>
_gestureRecognizers = <Type, GestureRecognizerFactory<GestureRecognizer>>{};
final double scaleMultiplier = -0.0001;
double _currentScale = 1.0;
Offset? _previousDragPosition;
static double _keepScaleWithinBounds(double scale) {
const double minScale = 1.0;
const double maxScale = 30;
if (scale <= 0) {
return minScale;
}
if (scale >= 30) {
return maxScale;
}
return scale;
}
void _zoomLogic(Offset currentDragPosition) {
final double dx = (_previousDragPosition!.dx - currentDragPosition.dx)
.abs();
final double dy = (_previousDragPosition!.dy - currentDragPosition.dy)
.abs();
if (dx > dy) {
// Ignore horizontal drags.
_previousDragPosition = currentDragPosition;
return;
}
if (currentDragPosition.dy < _previousDragPosition!.dy) {
// Zoom out on drag up.
setState(() {
_currentScale += currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
} else {
// Zoom in on drag down.
setState(() {
_currentScale -= currentDragPosition.dy * scaleMultiplier;
_currentScale = _keepScaleWithinBounds(_currentScale);
});
}
_previousDragPosition = currentDragPosition;
}
void _initGestures() {
_gestureRecognizers[TapAndPanGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner: this),
(TapAndPanGestureRecognizer instance) {
instance
..dragStartBehavior = DragStartBehavior.down
..maxConsecutiveTap = 3
..onTapDown = (TapDragDownDetails details) {
_previousDragPosition = details.globalPosition;
}
..onTapUp = (TapDragUpDetails details) {
if (details.consecutiveTapCount == 2) {
setState(() {
if (_currentScale == 1.0) {
_currentScale = 2.0;
} else {
_currentScale = 1.0;
}
});
}
}
..onDragUpdate = (TapDragUpdateDetails details) {
if (details.consecutiveTapCount == 3) {
_zoomLogic(details.globalPosition);
}
};
},
);
_gestureRecognizers[ScaleGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(debugOwner: this),
(ScaleGestureRecognizer instance) {
instance
.onUpdate = (ScaleUpdateDetails details) {
setState(() {
_currentScale = details.scale;
});
};
},
);
}
@override
void initState() {
super.initState();
_initGestures();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scale Gesture Example')),
body: Center(
child: RawGestureDetector(
gestures: _gestureRecognizers,
child: Transform.scale(
scale: _currentScale,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.lightBlueAccent,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(
alpha: (255.0 * 0.5).roundToDouble(),
),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: const Center(child: FlutterLogo(size: 80)),
),
),
),
),
);
}
}
Regarding detecting a double tap, can we just use
I tested your |
@Renzo-Olivares Thanks for taking a look and for those comments.
The use case, if I remember correctly, was that ScaleGestureRecognizer would never win the arena because TapAndPanGestureRecognizer would always win, which is what I wrote in the a comment in the "before" example:
However after testing this again the scale does usually win. I'm not sure if something changed since then. But there is still one case where the scale should win for my use case but it does not: If you place one pointer down and do not drag or lift the pointer for > 3 seconds, then begin to drag after 3 seconds, the TapAndPanGestureRecognizer will win. I am using a ScaleGestureRecognizer to handle both zooming and panning so it would be better if I didn't have to handle this case within the TapAndPanGestureRecognizer, although I suppose I could theoretically. Here's a video showing this, specifically the last two gestures in the video. Those last two gestures should be panning the list. untitled.mp4full example code: codeimport 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
void main() {
runApp(const MaterialApp(
home: ZoomViewExample(),
));
}
class ZoomViewExample extends StatefulWidget {
const ZoomViewExample({super.key});
@override
State<ZoomViewExample> createState() => _ZoomViewExampleState();
}
class _ZoomViewExampleState extends State<ZoomViewExample> with TickerProviderStateMixin {
ScrollController controller = ScrollController();
ZoomViewGestureHandler handler = ZoomViewGestureHandler(zoomLevels: [2, 1]);
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: ZoomView(
controller: controller,
minScale: 0.5,
maxScale: double.infinity,
onDoubleTapDown: (ZoomViewDetails zoomViewDetails) {
return handler.onDoubleTap(zoomViewDetails);
},
child: ListView.builder(
controller: controller,
itemCount: 10000,
physics: BouncingScrollPhysics(),
itemBuilder: (context, index) {
return Center(
child: Text("Text $index"),
);
}),
),
),
],
),
);
}
}
///Allows a ListView or other Scrollables that implement ScrollPosition and
///jumpTo(offset) in their controller to be zoomed and scrolled.
class ZoomView extends StatefulWidget {
const ZoomView({
super.key,
required this.child,
required this.controller,
this.maxScale = 4.0,
this.minScale = 1.0,
this.onDoubleTapDown,
this.scrollAxis = Axis.vertical,
});
///Callback invoked after a double tap down.
///This is set by the user but will generally be [ZoomViewGestureHandler.onDoubleTap] or null.
final void Function(ZoomViewDetails details)? onDoubleTapDown;
final Widget child;
final ScrollController controller;
///scrollAxis must be set to Axis.horizontal if the Scrollable is horizontal
final Axis scrollAxis;
///The maximum scale that the ZoomView can be zoomed to. Set to double.infinity to allow infinite zoom in
final double maxScale;
///The minimum scale that the ZoomView can be zoomed to. Set to 0 to allow infinite zoom out
final double minScale;
@override
State<ZoomView> createState() => _ZoomViewState();
}
class _ZoomViewState extends State<ZoomView> with TickerProviderStateMixin {
@override
void initState() {
if (widget.scrollAxis == Axis.vertical) {
_verticalController = widget.controller;
_horizontalController = ScrollController();
} else {
_verticalController = ScrollController();
_horizontalController = widget.controller;
}
_verticalTouchHandler = _TouchHandler(controller: _verticalController);
_horizontalTouchHandler = _TouchHandler(controller: _horizontalController);
_animationController = AnimationController.unbounded(vsync: this)
..addListener(() {
_updateScale(_animationController.value);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_verticalAnimationController =
AnimationController.unbounded(vsync: _verticalController.position.context.vsync)
..addListener(() {
_verticalController.jumpTo(_verticalAnimationController.value);
});
_horizontalAnimationController =
AnimationController.unbounded(vsync: _horizontalController.position.context.vsync)
..addListener(() {
_horizontalController.jumpTo(_horizontalAnimationController.value);
});
});
_maxScale = 1 / widget.maxScale;
_minScale = 1 / widget.minScale;
super.initState();
}
///The current scale of the ZoomView
double _scale = 1;
///The scale of the ZoomView before the last scale update event
double _lastScale = 1;
///Used for trackpad pointerEvents to determine if the user is panning or scaling
late TrackPadState _trackPadState;
///Total distance the trackpad has moved vertically since the last scale start event
Size _globalTrackpadDistance = Size.zero;
///Used to by double tap to animate to a new scale
late final AnimationController _animationController;
///Used by double tap to animate the vertical scroll position
late final AnimationController _verticalAnimationController;
///Used by double tap to animate the horizontal scroll position
late final AnimationController _horizontalAnimationController;
late final ScrollController _verticalController;
late final ScrollController _horizontalController;
late final _TouchHandler _verticalTouchHandler;
late final _TouchHandler _horizontalTouchHandler;
late final double _maxScale;
late final double _minScale;
Offset? _previousDragPosition;
final VelocityTracker _tracker = VelocityTracker.withKind(
PointerDeviceKind.touch,
);
late TapDownDetails _tapDownDetails;
///The focal point of pointers at the start of a scale event
late Offset _localFocalPoint;
void _updateScale(double scale) {
setState(() {
_scale = scale;
_lastScale = scale;
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
double height = constraints.maxHeight;
double width = constraints.maxWidth;
//The listener is only needed for trackpad events
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (PointerDownEvent event) {
_trackPadState = event.kind == PointerDeviceKind.trackpad
? TrackPadState.waiting
: TrackPadState.none;
},
onPointerPanZoomStart: (PointerPanZoomStartEvent event) {
_trackPadState = event.kind == PointerDeviceKind.trackpad
? TrackPadState.waiting
: TrackPadState.none;
},
child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
ScaleGestureRecognizer:
GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
(ScaleGestureRecognizer instance) {
instance
.. onStart = (ScaleStartDetails details) {
if (details.pointerCount == 1) {
DragStartDetails dragDetails = DragStartDetails(
globalPosition: details.focalPoint,
kind: PointerDeviceKind.touch,
);
_verticalTouchHandler.handleDragStart(dragDetails);
_horizontalTouchHandler.handleDragStart(dragDetails);
} else {
_localFocalPoint = details.localFocalPoint;
}
}
.. onUpdate = (ScaleUpdateDetails details) {
//If the trackpad has not moved enough to determine the
//gesture type, then wait for it to move more
if (_trackPadState == TrackPadState.waiting) {
if (details.scale != 1.0) {
_trackPadState = TrackPadState.scale;
} else {
_globalTrackpadDistance += details.focalPointDelta * _scale;
if (_globalTrackpadDistance.longestSide > kPrecisePointerPanSlop) {
_trackPadState = TrackPadState.pan;
DragStartDetails dragDetails = DragStartDetails(
globalPosition: details.focalPoint,
kind: PointerDeviceKind.touch,
);
_verticalTouchHandler.handleDragStart(dragDetails);
_horizontalTouchHandler.handleDragStart(dragDetails);
}
}
} else if (details.pointerCount > 1 && _trackPadState == TrackPadState.none ||
_trackPadState == TrackPadState.scale) {
final newScale = _clampDouble(_lastScale / details.scale, _maxScale, _minScale);
final verticalOffset =
_verticalController.position.pixels + (_scale - newScale) * _localFocalPoint.dy;
final horizontalOffset = _horizontalController.position.pixels +
(_scale - newScale) * _localFocalPoint.dx;
//This is the main logic to actually perform the scaling
setState(() {
_scale = newScale;
});
_verticalController.jumpTo(verticalOffset);
_horizontalController.jumpTo(horizontalOffset);
} else {
final correctedDelta = details.focalPointDelta * _scale;
final correctedOffset = details.focalPoint * _scale;
final time = details.sourceTimeStamp!;
_tracker.addPosition(time, correctedOffset);
final DragUpdateDetails verticalDetails = DragUpdateDetails(
globalPosition: correctedOffset,
sourceTimeStamp: time,
primaryDelta: correctedDelta.dy,
delta: Offset(0.0, correctedDelta.dy),
);
final DragUpdateDetails horizontalDetails = DragUpdateDetails(
globalPosition: correctedOffset,
sourceTimeStamp: time,
primaryDelta: correctedDelta.dx,
delta: Offset(correctedDelta.dx, 0.0),
);
_verticalTouchHandler.handleDragUpdate(verticalDetails);
_horizontalTouchHandler.handleDragUpdate(horizontalDetails);
}
}
.. onEnd = (ScaleEndDetails details) {
_trackPadState = TrackPadState.none;
_globalTrackpadDistance = Size.zero;
_lastScale = _scale;
Offset velocity = _tracker.getVelocity().pixelsPerSecond;
DragEndDetails endDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(0.0, velocity.dy)),
primaryVelocity: velocity.dy,
);
DragEndDetails hEndDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(velocity.dx, 0.0)),
primaryVelocity: velocity.dx,
);
_verticalTouchHandler.handleDragEnd(endDetails);
_horizontalTouchHandler.handleDragEnd(hEndDetails);
};
}),
TapAndPanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer( ),
(TapAndPanGestureRecognizer instance) {
instance
..onTapDown = (TapDragDownDetails details) {
print("onTapDown ${details.consecutiveTapCount}");
}
..onTapUp = widget.onDoubleTapDown == null
? null
:(TapDragUpDetails details){
if(details.consecutiveTapCount > 1){
final tapDownDetails = TapDownDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
kind: details.kind,
);
ZoomViewDetails zoomViewDetails = ZoomViewDetails(
height: height,
width: width,
scale: _scale,
updateScale: _updateScale,
tapDownDetails: tapDownDetails,
verticalController: _verticalController,
horizontalController: _horizontalController,
animationController: _animationController,
verticalAnimationController: _verticalAnimationController,
horizontalAnimationController: _horizontalAnimationController,
);
setState(() {
widget.onDoubleTapDown!(zoomViewDetails);
});
}
}
..onDragStart = (TapDragStartDetails details) {
_previousDragPosition ??= details.localPosition;
}
..onDragUpdate = (TapDragUpdateDetails details) {
if(details.consecutiveTapCount > 1){
final currentDragPosition = details.localPosition;
final double dx = (_previousDragPosition!.dx - currentDragPosition.dx);
double dy = (_previousDragPosition!.dy - currentDragPosition.dy);
if (dx.abs() > dy.abs()) {
// Ignore horizontal drags.
return;
}
const double scaleMultiplier = 200;
final newScale = _clampDouble(_lastScale + _lastScale + (dy/scaleMultiplier), _maxScale, _minScale);
final verticalOffset =
_verticalController.position.pixels + (_scale - newScale) * details.localPosition.dy;
final horizontalOffset = _horizontalController.position.pixels +
(_scale - newScale) * details.localPosition.dx;
//This is the main logic to actually perform the scaling
setState(() {
_scale = newScale;
});
_verticalController.jumpTo(verticalOffset);
_horizontalController.jumpTo(horizontalOffset);
}
}
..onDragEnd = (TapDragEndDetails details) {
_previousDragPosition = null;
print("Drag End");
};
},
),
},
child: Column(
children: [
Expanded(
//When scale decreases, the SizedBox will shrink and the FittedBox
//will scale the child to fit the maximum constraints of the ZoomView
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox(
height: height * _scale,
width: width * _scale,
child: Center(
child: ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(
overscroll: false,
//Disable all inputs on the list as we will handle them
//ourselves using the gesture detector and scroll controllers
dragDevices: <PointerDeviceKind>{},
scrollbars: false,
),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
controller: widget.scrollAxis == Axis.vertical
? _horizontalController
: _verticalController,
scrollDirection: widget.scrollAxis == Axis.vertical
? Axis.horizontal
: Axis.vertical,
child: SizedBox(
width: widget.scrollAxis == Axis.vertical ? width : null,
height: widget.scrollAxis == Axis.vertical ? null : height,
child: widget.child,
),
),
),
),
),
),
),
],
),
),
);
},
);
}
}
///keeps record of the state of a trackpad. Should be set to none if
///the PointerDeviceKind is not a trackpad
enum TrackPadState { none, waiting, pan, scale }
///Details needed to perform a double tap zoom
final class ZoomViewDetails {
final TapDownDetails tapDownDetails;
final double height;
final double width;
final Function updateScale;
final ScrollController verticalController;
final ScrollController horizontalController;
final AnimationController animationController;
final AnimationController verticalAnimationController;
final AnimationController horizontalAnimationController;
final double scale;
ZoomViewDetails({
required this.verticalController,
required this.horizontalController,
required this.tapDownDetails,
required this.height,
required this.width,
required this.updateScale,
required this.animationController,
required this.verticalAnimationController,
required this.horizontalAnimationController,
required this.scale,
});
///Calculates the new vertical offset for the scroll controller
double getVerticalOffset(double newScale) {
return verticalController.position.pixels +
(scale - newScale) * tapDownDetails.localPosition.dy;
}
///Calculates the new horizontal offset for the scroll controller
double getHorizontalOffset(double newScale) {
return horizontalController.position.pixels +
(scale - newScale) * tapDownDetails.localPosition.dx;
}
}
///Handles the logic for a double tap zoom via the [ZoomView.onDoubleTapDown] callback
final class ZoomViewGestureHandler {
int _index = 0;
final List<double> zoomLevels;
final Duration duration;
ZoomViewGestureHandler({
required this.zoomLevels,
this.duration = const Duration(milliseconds: 100),
});
void onDoubleTap(ZoomViewDetails zoomViewDetails) {
late double newScale;
if (zoomViewDetails.scale > 1.0 && 1 == 1) {
newScale = 1;
_index = 0;
} else {
newScale = 1 / zoomLevels[_index];
_index++;
if (_index == zoomLevels.length) {
_index = 0;
}
}
final verticalOffset = zoomViewDetails.getVerticalOffset(newScale);
final horizontalOffset = zoomViewDetails.getHorizontalOffset(newScale);
if (duration != const Duration(milliseconds: 0)) {
zoomViewDetails.animationController
..value = zoomViewDetails.scale
..animateTo(
newScale,
duration: duration,
curve: Curves.linear,
);
zoomViewDetails.verticalAnimationController
..value = zoomViewDetails.verticalController.position.pixels
..animateTo(
verticalOffset,
duration: duration,
curve: Curves.linear,
);
zoomViewDetails.horizontalAnimationController
..value = zoomViewDetails.horizontalController.position.pixels
..animateTo(
horizontalOffset,
duration: duration,
curve: Curves.linear,
);
/*
final animationController = zoomViewDetails.animationController;
animationController.duration = duration;
animationController.value = 0.0;
late final Animation s;
s = Tween(begin: zoomViewDetails.scale, end: newScale).animate(animationController)
..addListener(() {
zoomViewDetails.updateScale(s.value);
});
late final Animation v;
v = Tween(begin: zoomViewDetails.verticalController.position.pixels, end: verticalOffset)
.animate(animationController)
..addListener(() {
zoomViewDetails.verticalController.jumpTo(v.value);
});
late final Animation h;
h = Tween(begin: zoomViewDetails.horizontalController.position.pixels, end: horizontalOffset)
.animate(animationController)
..addListener(() {
zoomViewDetails.horizontalController.jumpTo(h.value);
});
animationController.animateTo(1.0);
*/
} else {
zoomViewDetails.updateScale(newScale);
zoomViewDetails.horizontalController.jumpTo(horizontalOffset);
zoomViewDetails.verticalController.jumpTo(verticalOffset);
}
}
}
///Touch handlers coppied from Flutter ScrollableState
final class _TouchHandler {
final ScrollController controller;
_TouchHandler({required this.controller});
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey =
GlobalKey<RawGestureDetectorState>();
Drag? _drag;
ScrollHoldController? _hold;
void handleDragDown(DragDownDetails details) {
assert(_drag == null);
assert(_hold == null);
_hold = controller.position.hold(disposeHold);
}
void handleDragStart(DragStartDetails details) {
assert(_drag == null);
_drag = controller.position.drag(details, disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
void handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
void handleDragEnd(DragEndDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.end(details);
assert(_drag == null);
}
void handleDragCancel() {
if (_gestureDetectorKey.currentContext == null) {
return;
}
// _hold might be null if the drag started.
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_hold?.cancel();
_drag?.cancel();
assert(_hold == null);
assert(_drag == null);
}
void disposeHold() {
_hold = null;
}
void disposeDrag() {
_drag = null;
}
}
double _clampDouble(double x, double min, double max) {
if (x < min) {
return min;
}
if (x > max) {
return max;
}
return x;
}
The exact use case is to add tap and drag to zoom to this package: https://github.com/yakagami/zoom_view. The package allows a user to zoom into a ListView, which is not supported by Flutter natively. To do this it intercepts all drag events on the List via a ScaleGestureRecognizer. I guess what I'm trying to do with this PR is to make TapAndPanGestureRecognizer be able to be just a double tap and tap and drag gesture recognizer. I would not like it to try to win the arena before it meets the minimum number of taps. Although, asuming TapAndPanGestureRecognizer can reliably detect double taps as our wrote below, maybe the correct solution here really is to add a TapAndScaleGestureRecognizer that handles everything. That might be the most clean API for other developers wanting the same features. I'm not sure how much work that would be but if you think that would be better Id be open to doing that instead.
I think you are right here. I must have missed that. |
Hi @yakagami, thank you for your patience on this one. That is an awesome package! This is definitely a tricky use-case. Handling some of the pan logic in |
This adds a
minTaps
parameter toBaseTapAndDragGestureRecognizer
andTapAndPanGestureRecognizer
, allowingScaleGestureRecognizer
to resolve on single taps andTapAndPanGestureRecognizer
to only win when there is a double tap first by settingminTaps
to 2. See the related issue "Add double tap and pan/double tap and drag gesture (#164889)"Closes #164889
Note: we should probably check that
minTaps
<=maxConsecutiveTap
. Not sure where to check for that. I guess we would add setters formaxConsecutiveTap
andminTaps
and check there?Before
After
Pre-launch Checklist
///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.