Flutter: enable image zoom in/out on double tap using InteractiveViewer - android

I want to enable zoom in and out on double tap of the image, together with scaling in/out on pinch.
I saw some tutorials on YouTube where they implemented this feature using GestureDetector like this one but for some reason, it didn't work out for me.
In order to implement scaling in/out on pinch, I relied on this answer, and it really works well, but I also want to enable zoom in/out on double tapping the image. Looking up a way to do so on the internet, unfortunately, yielded nothing.
Is there any way to enable zoom in/out with both pinch and double tap using InteractiveViewer?
here is my code:
#override
Widget build(BuildContext context) {
return Center(
child: InteractiveViewer(
boundaryMargin: EdgeInsets.all(80),
panEnabled: false,
scaleEnabled: true,
minScale: 1.0,
maxScale: 2.2,
child: Image.network("https://pngimg.com/uploads/muffin/muffin_PNG123.png",
fit: BoxFit.fitWidth,
)
),
);
}

You can use a GestureDetector, that gives you the position of the click and with that you can zoom with the TransformationController at the click position:
final _transformationController = TransformationController();
TapDownDetails _doubleTapDetails;
#override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTapDown: _handleDoubleTapDown,
onDoubleTap: _handleDoubleTap,
child: Center(
child: InteractiveViewer(
transformationController: _transformationController,
/* ... */
),
),
);
}
void _handleDoubleTapDown(TapDownDetails details) {
_doubleTapDetails = details;
}
void _handleDoubleTap() {
if (_transformationController.value != Matrix4.identity()) {
_transformationController.value = Matrix4.identity();
} else {
final position = _doubleTapDetails.localPosition;
// For a 3x zoom
_transformationController.value = Matrix4.identity()
..translate(-position.dx * 2, -position.dy * 2)
..scale(3.0);
// Fox a 2x zoom
// ..translate(-position.dx, -position.dy)
// ..scale(2.0);
}
}

To animate the transition on double tap, you have to create an explicit animation on top of Till's code.
class _WidgetState extends State<Widget> with SingleTickerProviderStateMixin {
.
.
.
AnimationController _animationController;
Animation<Matrix4> _animation;
#override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 400),
)..addListener(() {
_transformationController.value = _animation.value;
});
}
#override
void dispose() {
_animationController.dispose();
super.dispose();
}
.
.
.
void _handleDoubleTap() {
Matrix4 _endMatrix;
Offset _position = _doubleTapDetails.localPosition;
if (_transformationController.value != Matrix4.identity()) {
_endMatrix = Matrix4.identity();
} else {
_endMatrix = Matrix4.identity()
..translate(-_position.dx * 2, -_position.dy * 2)
..scale(3.0);
}
_animation = Matrix4Tween(
begin: _transformationController.value,
end: _endMatrix,
).animate(
CurveTween(curve: Curves.easeOut).animate(_animationController),
);
_animationController.forward(from: 0);
}
.
.
.
}

Here's a full, portable solution with included customizable animation:
class DoubleTappableInteractiveViewer extends StatefulWidget {
final double scale;
final Duration scaleDuration;
final Curve curve;
final Widget child;
const DoubleTappableInteractiveViewer({
super.key,
this.scale = 2,
this.curve = Curves.fastLinearToSlowEaseIn,
required this.scaleDuration,
required this.child,
});
#override
State<DoubleTappableInteractiveViewer> createState() => _DoubleTappableInteractiveViewerState();
}
class _DoubleTappableInteractiveViewerState extends State<DoubleTappableInteractiveViewer>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
Animation<Matrix4>? _zoomAnimation;
late TransformationController _transformationController;
TapDownDetails? _doubleTapDetails;
#override
void initState() {
super.initState();
_transformationController = TransformationController();
_animationController = AnimationController(
vsync: this,
duration: widget.scaleDuration,
)..addListener(() {
_transformationController.value = _zoomAnimation!.value;
});
}
#override
void dispose() {
_transformationController.dispose();
_animationController.dispose();
super.dispose();
}
void _handleDoubleTapDown(TapDownDetails details) {
_doubleTapDetails = details;
}
void _handleDoubleTap() {
final newValue =
_transformationController.value.isIdentity() ?
_applyZoom() : _revertZoom();
_zoomAnimation = Matrix4Tween(
begin: _transformationController.value,
end: newValue,
).animate(
CurveTween(curve: widget.curve)
.animate(_animationController)
);
_animationController.forward(from: 0);
}
Matrix4 _applyZoom() {
final tapPosition = _doubleTapDetails!.localPosition;
final translationCorrection = widget.scale - 1;
final zoomed = Matrix4.identity()
..translate(
-tapPosition.dx * translationCorrection,
-tapPosition.dy * translationCorrection,
)
..scale(widget.scale);
return zoomed;
}
Matrix4 _revertZoom() => Matrix4.identity();
#override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTapDown: _handleDoubleTapDown,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController,
child: widget.child,
),
);
}
}
Example usage:
DoubleTappableInteractiveViewer(
scaleDuration: const Duration(milliseconds: 600),
child: Image.network(imageUrl),
),
Play around with it on dartpad.

Related

How to create an infinite rain of icons in Flutter?

I want to create an infinite rain of icons in Flutter. Let me explain.
Here is the widget that will be performing the raining/falling animation. It uses an AnimationController along with a GravitySimulation to make the widget fall. It takes in an endDistance, which is how far it should go down. It renders a simple icon (also an argument).
import 'package:clickr/providers/GameEngineProvider.dart';
import 'package:clickr/utils/functions/randInt.dart';
import "package:flutter/material.dart";
import 'package:flutter/physics.dart';
import 'package:provider/provider.dart';
class FallingIcon extends StatefulWidget {
final double endDistance;
final double? iconLeft;
final double? iconRight;
final double? iconBottom;
final Widget? child;
const FallingIcon({
Key? key,
required this.endDistance,
this.iconLeft,
this.iconRight,
this.iconBottom,
this.child,
}) : super(key: key);
#override
_FallingIconState createState() => _FallingIconState();
}
class _FallingIconState extends State<FallingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
#override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(
milliseconds: randInt(2000, 3000),
),
);
_controller.animateWith(
GravitySimulation(
10,
0,
widget.endDistance,
0,
),
);
_controller.forward();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
return Positioned(
child: child!,
top: _controller.value * widget.endDistance,
left: widget.iconLeft,
right: widget.iconRight,
bottom: widget.iconBottom,
);
},
child: widget.child ?? const FlutterLogo(),
);
}
}
Here, I am using this widget inside a stack:
import 'package:clickr/providers/GameEngineProvider.dart';
import 'package:clickr/providers/IconSetsProvider.dart';
import 'package:clickr/screens/HomeScreen.Authenticated.dart';
import 'package:clickr/widgets/CircleSticker.dart';
import 'package:clickr/widgets/FallingIcon.dart';
import 'package:clickr/widgets/StatusBarSpacer.dart';
import "package:flutter/material.dart";
import 'package:provider/provider.dart';
import '../widgets/FloatingActionButtonCustomBar.dart';
class PlayScreen extends StatefulWidget {
const PlayScreen({Key? key}) : super(key: key);
static const routeName = "/play";
#override
State<PlayScreen> createState() => _PlayScreenState();
}
class _PlayScreenState extends State<PlayScreen> {
#override
void initState() {
super.initState();
}
#override
Widget build(BuildContext context) {
final iconSetsProvider =
Provider.of<IconSetsProvider>(context, listen: false);
final gameEngineProvider =
Provider.of<GameEngineProvider>(context, listen: true);
return Scaffold(
body: Column(
children: [
const StatusBarSpacer(),
Expanded(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Stack(
children: [
FallingIcon(
endDistance: constraints.maxHeight,
child: Image.asset(
iconSetsProvider.getRandomIconFromIconSet(
iconSetsProvider.currentIconSet!,
),
width: 40,
),
),
],
);
},
),
),
FloatingActionButtonCustomBar(
children: [
FloatingActionButton(
onPressed: () {
Navigator.of(context).pushReplacementNamed(
HomeScreenAuthenticatated.routeName);
},
child: const Icon(Icons.home),
heroTag: "home",
),
FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.pause),
heroTag: "pause",
),
],
),
],
),
);
}
}
The above code renders one FallingIcon, which successfully animates and falls until it reaches the end of the screen and cannot be seen any more. Now, I want to multiply this icon, so there should be multiple icons falling on the screen. And I want this to never stop. It should just be an infinite rain of icons. And it shouldn't be systematic, like one wave after the other. It should just be random infinite rain of icons.
I have attempted to use a stream and a new event would be pushed onto the stream randomly. But then I couldn't figure out how to render FallingIcons based on the items in the stream.
I wonder if this is possible in Flutter. Thanks.
this kind of "raining/falling animation" could be implemented with one CustomPaint widget (instead of rebuilding lots of widgets with AnimatedBuilder)
the core idea is to provide a Listenable variable to CustomPainter.repaint property (see super(repaint: ...) below) - of course you need to change your CustomPainter so that it draws your vertically falling images
class FooSpritePaint extends StatefulWidget {
#override
State<FooSpritePaint> createState() => _FooSpritePaintState();
}
class _FooSpritePaintState extends State<FooSpritePaint> with TickerProviderStateMixin {
late Ticker ticker;
final notifier = ValueNotifier(Duration.zero);
ui.Image? sprite;
#override
void initState() {
super.initState();
ticker = Ticker(_tick);
rootBundle.load('images/sprites.png')
.then((data) => decodeImageFromList(data.buffer.asUint8List()))
.then(_setSprite);
}
_tick(Duration d) => notifier.value = d;
_setSprite(ui.Image image) {
setState(() {
// print('image: $image');
sprite = image;
ticker.start();
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomPaint(
foregroundPainter: SpritePainter(sprite, notifier),
child: Center(
child: TextButton(
onPressed: () => ticker.isTicking? ticker.stop() : ticker.start(),
child: const Text('click to stop / start', textScaleFactor: 1.5),
),
),
),
);
}
}
class SpritePainter extends CustomPainter {
final ui.Image? sprite;
final ValueNotifier<Duration> notifier;
final p = Paint();
SpritePainter(this.sprite, this.notifier) : super(repaint: notifier);
#override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
if (sprite != null) {
final ms = notifier.value.inMilliseconds;
final frame = ms ~/ 80;
// print(frame);
final frames = [frame, frame + 2, frame + 4].map((f) => f % 6).toList();
final spritePhases = [
phase(size.width, ms * 0.066),
phase(size.width, ms * 0.075 + 40),
phase(size.width, ms * 0.075 + 80),
];
final transforms = [
for (int i = 0; i < spritePhases.length; i++)
ui.RSTransform(1, 0, spritePhases[i][0], size.height / 2 - 45),
];
final rects = [
for (int i = 0; i < spritePhases.length; i++)
Rect.fromLTWH(frames[i] * 100, spritePhases[i][1] * 100, 100, 100),
];
canvas.drawAtlas(sprite!, transforms, rects, null, null, null, p);
}
}
List<double> phase(double width, double x) {
final w = width + 100;
x = x % (2 * w);
return x < w? [x - 100, 0] : [2 * w - x - 100, 1];
}
#override
bool shouldRepaint(SpritePainter oldDelegate) => false;
}
the above sample code uses the following image that should be placed in images/sprites.png folder:
EDIT
and as a proof of concept here you have such a sample CustomPainter:
class Bird {
Bird(int ms, this.rect, List<double> r, Size size) :
startTimeMs = ms,
scale = lerpDouble(1, 0.3, r[0])!,
rotation = pi * lerpDouble(-1, 1, r[2])!,
xSimulation = FrictionSimulation(0.75, r[1] * size.width, lerpDouble(size.width / 2, -size.width / 2, r[1])!),
ySimulation = GravitySimulation(lerpDouble(10, 1000, r[0])!, -rect.height / 2, size.height + rect.height / 2, 100);
final int startTimeMs;
final Rect rect;
final Simulation xSimulation;
final Simulation ySimulation;
final double scale;
final double rotation;
double x(int ms) => xSimulation.x(_normalizeTime(ms));
double y(int ms) => ySimulation.x(_normalizeTime(ms));
bool isDead(int ms) => ySimulation.isDone(_normalizeTime(ms));
double _normalizeTime(int ms) => (ms - startTimeMs) / Duration.millisecondsPerSecond;
RSTransform transform(int ms, Size size) {
final translateY = y(ms);
return RSTransform.fromComponents(
translateX: x(ms),
translateY: translateY,
anchorX: rect.width / 2,
anchorY: rect.height / 2,
rotation: rotation * translateY / size.height,
scale: scale,
);
}
}
class FallingBirdsPainter extends CustomPainter {
final ui.Image? sprite;
final ValueNotifier<Duration> notifier;
final imagePaint = Paint();
final backgroundPaint = Paint()..color = Colors.black26;
final random = Random();
final birds = <Bird>[];
int nextReport = 0;
static const spriteRects = [
Rect.fromLTRB(000, 0, 103, 140),
Rect.fromLTRB(103, 0, 217, 140),
Rect.fromLTRB(217, 0, 312, 140),
Rect.fromLTRB(312, 0, 410, 140),
];
FallingBirdsPainter(this.sprite, this.notifier) : super(repaint: notifier);
#override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
canvas.drawPaint(backgroundPaint);
if (sprite != null) {
final ms = DateTime.now().millisecondsSinceEpoch;
if (random.nextDouble() < 0.15) {
// drop new bird
birds.add(Bird(ms, spriteRects[random.nextInt(4)], List.generate(3, (i) => random.nextDouble()), size));
}
final transforms = birds.map((bird) => bird.transform(ms, size)).toList();
final rects = birds.map((bird) => bird.rect).toList();
canvas.drawAtlas(sprite!, transforms, rects, null, null, null, imagePaint);
// dead birds cleanup
birds.removeWhere((bird) => bird.isDead(ms));
if (ms >= nextReport) {
nextReport = ms + 6000;
print('flying birds population: ${birds.length}');
}
}
}
#override
bool shouldRepaint(FallingBirdsPainter oldDelegate) => false;
}
it uses the following image:
all you need is to replace
foregroundPainter: SpritePainter(sprite, notifier),
with
foregroundPainter: FallingBirdsPainter(sprite, notifier),
and
rootBundle.load('images/sprites.png')
with
rootBundle.load('images/birds.png')
the final result is similar to this:

How to move around your widgets (Image) after zooming (Flutter)?

I want to zoom an image in flutter and I am able to do it using GestureDetector and TransForm but I am not able to move around my image . It is just zooming . I want implement something dragging so that after zooming I can move around my image .
How can I do this ?
P.S. I tried using onPanStart ... in GestureDetector but I can't use both onScaleStart and onPanStart .
Here is my code :
class _ImageViewState extends State<ImageView> {
File img;
double _scale = 1.0;
double _prev = 1.0;
_ImageViewState(this.img);
#override
Widget build(BuildContext context) {
return SafeArea(
child: GestureDetector(
onScaleStart: (ScaleStartDetails details){
setState(() {
_scale = _prev;
});
},
onScaleUpdate: (ScaleUpdateDetails details){
setState(() {
_scale = _prev * details.scale;
});
print(_scale);
},
onScaleEnd: (ScaleEndDetails details){
setState(() {
_prev = _scale;
});
},
child : Transform(
alignment : FractionalOffset.center,
transform: Matrix4.diagonal3(Vector3(_scale , _scale , _scale)),
child: Image.file(img),
)
),
);
}
}
I have tried to implement something similar in my project. I'm using the matrix_gesture_detector package and created the following custom widget:
import 'package:flutter/material.dart';
import 'package:matrix_gesture_detector/matrix_gesture_detector.dart';
class ZoomableWidget extends StatefulWidget {
final Widget child;
const ZoomableWidget({Key key, this.child}) : super(key: key);
#override
_ZoomableWidgetState createState() => _ZoomableWidgetState();
}
class _ZoomableWidgetState extends State<ZoomableWidget> {
Matrix4 matrix = Matrix4.identity();
Matrix4 zerada = Matrix4.identity();
#override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: (){
setState(() {
matrix = zerada;
});
},
child: MatrixGestureDetector(
shouldRotate: false,
onMatrixUpdate: (Matrix4 m, Matrix4 tm, Matrix4 sm, Matrix4 rm) {
setState(() {
matrix = m;
});
},
child: Transform(
transform: matrix,
child: widget.child,
),
),
);
}
}

How to run animation after specified duration in proper way?

Let's say I have 3 shapes in Stack widget which needs to be moved from point A to point B. I would like to start these 3 animations after specified delay 0ms 1000ms 2000ms .. . So for that I have 3 separated AnimationController objects but I don't see constructor parameter like delay:. I tried to run forward method 3 times in loop using
int delay = 0;
for (final AnimationController currentController in controllers) {
Future.delayed(Duration(milliseconds: delay), () {
currentController.forward(from: value);
});
delay += 1000;
}
or
await Future.delayed(Duration(milliseconds: delay));
currentController.forward(from: value);
or using Timer class instead of Future but it doesn't work properly. In foreground its working good but when I move application to background and go back to foreground the gap between each shape disappearing and they are in the same position sticked together and moving like one shape.
You can make a stateful widget like below. Change the animation according to your needs.
class SlideUpWithFadeIn extends StatefulWidget {
final Widget child;
final int delay;
final Curve curve;
SlideUpWithFadeIn({#required this.child, #required this.curve, this.delay});
#override
_SlideUpWithFadeInState createState() => _SlideUpWithFadeInState();
}
class _SlideUpWithFadeInState extends State<SlideUpWithFadeIn>
with TickerProviderStateMixin {
AnimationController _animController;
Animation<Offset> _animOffset;
#override
void initState() {
super.initState();
_animController =
AnimationController(vsync: this, duration: Duration(milliseconds: 1250));
final curve =
CurvedAnimation(curve: widget.curve, parent: _animController);
_animOffset =
Tween<Offset>(begin: const Offset(0.0, 0.75), end: Offset.zero)
.animate(curve);
if (widget.delay == null) {
_animController.forward();
} else {
Timer(Duration(milliseconds: widget.delay), () {
_animController.forward();
});
}
}
#override
void dispose() {
_animController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return FadeTransition(
child: SlideTransition(
position: _animOffset,
child: widget.child,
),
opacity: _animController,
);
}
}
And use it like
SlideUpWithFadeIn(
child: ...,
delay: 0,
curve: ...,
),
SlideUpWithFadeIn(
child: ...,
delay: 1000,
curve: ...,
),
SlideUpWithFadeIn(
child: ...,
delay: 2000,
curve: ...,
),

CustomPainter not painting at the correct position after device orientation changes

I'm trying to learn Flutter. I'm drawing a diagonal line using CustomPainter on a centered square container whenever the container is tapped. The CustomPainter draws the line correctly.
However the line changes its position when the device orientation changes and is no longer in the center of the container.
I'm using didChangeMetrics() to detect when device orientation changes and redrawing the line. But this time the line is not drawn in the center of the container as intended. However if the container is tapped again the line is drawn correctly. I'm calling the same function to draw the line at both the places didChangeMetrics and onTapDown.
I'm using the following code :
import 'package:flutter/material.dart';
import 'dart:ui';
void main() {
runApp(MaterialApp(home: HomePage()));
}
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
Offset start = Offset.zero;
Offset end = Offset.zero;
double winLineWidth = 0;
GlobalKey _containerKey = GlobalKey();
#override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
#override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
}
#override
void didChangeMetrics() {
_drawLine();
}
void _drawLine() {
RenderBox box = _containerKey.currentContext.findRenderObject();
var _boxOffset = box.getTransformTo(null).getTranslation();
RenderBox getBox = context.findRenderObject();
print(getBox.globalToLocal( Offset(_boxOffset.x, _boxOffset.y) ));
var appBarHeight = 80;
setState(() {
start = Offset(_boxOffset.x, _boxOffset.y - appBarHeight);
end = Offset(_boxOffset.x + 100, _boxOffset.y - appBarHeight + 100);
print('$start $end');
winLineWidth = 2;
});
}
#override
Widget build(BuildContext context) {
AppBar appBar = AppBar(
title: Text('Demo'),
);
return Scaffold(
appBar: appBar,
body: Stack(
children: <Widget>[
Center(
child: GestureDetector(
onTapDown: (TapDownDetails tapDownDetails) {
_drawLine();
},
child: Container(key: _containerKey,width: 100,height: 100,color: Colors.black),
),
),
CustomPaint(painter:WinLine(start: start, end: end, winLineWidth: winLineWidth),
)
],
),
);
}
}
class WinLine extends CustomPainter {
Paint _paint;
Offset start, end;
double winLineWidth;
WinLine({this.start, this.end, this.winLineWidth = 8}) {
_paint = Paint()
..color = Colors.red
..strokeWidth = winLineWidth;
}
#override
void paint(Canvas canvas, Size size) {
canvas.drawLine(start, end, _paint);
}
#override
bool shouldRepaint(WinLine oldDelegate) {
return true;
}
}
How do I get the CustomPainter to again redraw the line at the center of the container even after the device changes its orientation.
Thanks for your time.
The only thing you have to do is to put your drawLine method inside a FrameCallback :
#override
void didChangeMetrics() {
SchedulerBinding.instance.addPostFrameCallback((_) {
_drawLine();
});
}

Flutter Execute Method so long the button pressed

I want to execute a method while a user is pressing down on a button. In pseudocode:
while (button.isPressed) {
executeCallback();
}
In other words, the executeCallback method should fire repeatedly as long as the user is pressing down on the button, and stop firing when the button is released. How can I achieve this in Flutter?
Use a Listener and a stateful widget. I also introduced a slight delay after every loop:
import 'dart:async';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(brightness: Brightness.dark),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
bool _buttonPressed = false;
bool _loopActive = false;
void _increaseCounterWhilePressed() async {
// make sure that only one loop is active
if (_loopActive) return;
_loopActive = true;
while (_buttonPressed) {
// do your thing
setState(() {
_counter++;
});
// wait a bit
await Future.delayed(Duration(milliseconds: 200));
}
_loopActive = false;
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Listener(
onPointerDown: (details) {
_buttonPressed = true;
_increaseCounterWhilePressed();
},
onPointerUp: (details) {
_buttonPressed = false;
},
child: Container(
decoration: BoxDecoration(color: Colors.orange, border: Border.all()),
padding: EdgeInsets.all(16.0),
child: Text('Value: $_counter'),
),
),
),
);
}
}
A simpler way, without the listener, is as follows:
GestureDetector(
child: InkWell(
child: Icon(Icons.skip_previous_rounded),
onTap: widget.onPrevious,
),
onLongPressStart: (_) async {
isPressed = true;
do {
print('long pressing'); // for testing
await Future.delayed(Duration(seconds: 1));
} while (isPressed);
},
onLongPressEnd: (_) => setState(() => isPressed = false),
);
}
Building on the solution from ThinkDigital, my observation is that InkWell contains all the events necessary to do this without an extra GestureDetector (I find that the GestureDetector interferes with the ink animation on long press). Here's a control I implemented for a pet project that fires its event with a decreasing delay when held (this is a rounded button with an icon, but anything using InkWell will do):
/// A round button with an icon that can be tapped or held
/// Tapping the button once simply calls [onUpdate], holding
/// the button will repeatedly call [onUpdate] with a
/// decreasing time interval.
class TapOrHoldButton extends StatefulWidget {
/// Update callback
final VoidCallback onUpdate;
/// Minimum delay between update events when holding the button
final int minDelay;
/// Initial delay between change events when holding the button
final int initialDelay;
/// Number of steps to go from [initialDelay] to [minDelay]
final int delaySteps;
/// Icon on the button
final IconData icon;
const TapOrHoldButton(
{Key? key,
required this.onUpdate,
this.minDelay = 80,
this.initialDelay = 300,
this.delaySteps = 5,
required this.icon})
: assert(minDelay <= initialDelay,
"The minimum delay cannot be larger than the initial delay"),
super(key: key);
#override
_TapOrHoldButtonState createState() => _TapOrHoldButtonState();
}
class _TapOrHoldButtonState extends State<TapOrHoldButton> {
/// True if the button is currently being held
bool _holding = false;
#override
Widget build(BuildContext context) {
var shape = CircleBorder();
return Material(
color: Theme.of(context).dividerColor,
shape: shape,
child: InkWell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
widget.icon,
color:
Theme.of(context).textTheme.headline1?.color ?? Colors.white70,
size: 36,
),
),
onTap: () => _stopHolding(),
onTapDown: (_) => _startHolding(),
onTapCancel: () => _stopHolding(),
customBorder: shape,
),
);
}
void _startHolding() async {
// Make sure this isn't called more than once for
// whatever reason.
if (_holding) return;
_holding = true;
// Calculate the delay decrease per step
final step =
(widget.initialDelay - widget.minDelay).toDouble() / widget.delaySteps;
var delay = widget.initialDelay.toDouble();
while (_holding) {
widget.onUpdate();
await Future.delayed(Duration(milliseconds: delay.round()));
if (delay > widget.minDelay) delay -= step;
}
}
void _stopHolding() {
_holding = false;
}
}
Here it is in action:
To improve Elte Hupkes's solution, I fixed an issue where the number of clicks and the number of calls to the onUpdate callback did not match when tapping consecutively.
_tapDownCount variable is additionally used.
import 'package:flutter/material.dart';
/// A round button with an icon that can be tapped or held
/// Tapping the button once simply calls [onUpdate], holding
/// the button will repeatedly call [onUpdate] with a
/// decreasing time interval.
class TapOrHoldButton extends StatefulWidget {
/// Update callback
final VoidCallback onUpdate;
/// Minimum delay between update events when holding the button
final int minDelay;
/// Initial delay between change events when holding the button
final int initialDelay;
/// Number of steps to go from [initialDelay] to [minDelay]
final int delaySteps;
/// Icon on the button
final IconData icon;
const TapOrHoldButton(
{Key? key,
required this.onUpdate,
this.minDelay = 80,
this.initialDelay = 300,
this.delaySteps = 5,
required this.icon})
: assert(minDelay <= initialDelay, "The minimum delay cannot be larger than the initial delay"),
super(key: key);
#override
_TapOrHoldButtonState createState() => _TapOrHoldButtonState();
}
class _TapOrHoldButtonState extends State<TapOrHoldButton> {
/// True if the button is currently being held
bool _holding = false;
int _tapDownCount = 0;
#override
Widget build(BuildContext context) {
var shape = const CircleBorder();
return Material(
color: Theme.of(context).dividerColor,
shape: shape,
child: InkWell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
widget.icon,
color: Theme.of(context).textTheme.headline1?.color ?? Colors.white70,
size: 36,
),
),
onTap: () => _stopHolding(),
onTapDown: (_) => _startHolding(),
onTapCancel: () => _stopHolding(),
customBorder: shape,
),
);
}
void _startHolding() async {
// Make sure this isn't called more than once for
// whatever reason.
widget.onUpdate();
_tapDownCount += 1;
final int myCount = _tapDownCount;
if (_holding) return;
_holding = true;
// Calculate the delay decrease per step
final step = (widget.initialDelay - widget.minDelay).toDouble() / widget.delaySteps;
var delay = widget.initialDelay.toDouble();
while (true) {
await Future.delayed(Duration(milliseconds: delay.round()));
if (_holding && myCount == _tapDownCount) {
widget.onUpdate();
} else {
return;
}
if (delay > widget.minDelay) delay -= step;
}
}
void _stopHolding() {
_holding = false;
}
}
USING SETSTATE
You can achieve this by simply "onLongPressStart" and "onLongPressEnd" properties of a button.
In case you can't find "onLongPressStart" / "onLongPressEnd" properties in your widget, wrap your widget with the "GestureDetector" widget.
GestureDetector(
child: ..,
onLongPressStart: (_) async {
isTap = true;
do {
await Future.delayed(Duration(seconds: 1));
} while (isTap );
},
onLongPressEnd: (_) => setState(() => isTap = false),
);
}

Categories

Resources