Flutter makes creating custom UI experiences easy. It really does. I mean it.
One example is complex gestures. How about starting a drag gesture to trigger something, and then moving the finger to control something else, and finally dropping it to confirm?
In this code tutorial, we will set up a screen showing a picture and an edit button. The user can drag the edit button onto the image.
When the edit button is on the image, finger still on screen, a red overlay is added. The finger position controls the opacity of the overlay. We move the finger up for more, down for less. When the user is happy, they can drop the edit icon (ie lift their finger).
If the user wants to cancel, they can drag the edit icon out of the image area and drop it there.
In the code tutorial, a snackbar is then shown, to confirm the edit was completed, or was cancelled. We do not actually edit the image, that’s beyond the scope of this tutorial.
The full source code is available on github.
General approach
For the drag gesture, we use Draggable and DragTarget.
To get the general position of the pointer, we can use a Listener widget.
We need to bear in mind that a listener can only get events if it exists at the time the “pointer down” event is fired (refer to Gestures documentation).
We will use a stream to track the edit state of the image.
Note: I have called it “BLoC” as it makes a great introduction to the topic, but we could call it “Presenter” or “Controller”. It basically is a class that is used by the views to change the edit state and to redraw themselves.
Setting up the app
To follow the code tutorial, create a new app as follows.
1 |
flutter create complexgestures |
If you’re unsure how to set up a Flutter app, check out Getting started with Flutter official tutorial.
Firstly, we add rxdart to pubspec.yaml and run flutter packages get .
1 2 3 4 5 6 7 8 9 |
dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.2 rxdart: ^0.19.0 |
Secondly, we create a Material app in main.dart. It launches MyHomePage, which displays the PhotoView and EditControlsView widgets.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import 'package:complexgestures/edit_controls_view.dart'; import 'package:complexgestures/photo_view.dart'; import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Complex Gestures Example', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Complex Gestures'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { Widget body = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Expanded(child: PhotoView()), EditControlsView(), ], ), ); return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: body, ); } } |
Thirdly, we create PhotoView and EditControlsView, in photo_view.dart and edit_controls_view.dart respectively.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import 'package:flutter/material.dart'; class PhotoView extends StatelessWidget { PhotoView({Key key}) : super(key: key); @override Widget build(BuildContext context) { return _buildImage(); } Widget _buildImage() { return Image.network("https://upload.wikimedia.org/wikipedia/commons/1/17/Google-flutter-logo.png"); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import 'package:flutter/material.dart'; class EditControlsView extends StatefulWidget { EditControlsView({Key key}) : super(key: key); @override _EditControlsViewState createState() => _EditControlsViewState(); } class _EditControlsViewState extends State<EditControlsView> { @override Widget build(BuildContext context) { return _buildIcon(); } Widget _buildIcon() { return Container( padding: EdgeInsets.all(16.0), color: Colors.grey, child: Icon(Icons.edit, color: Colors.black) ); } } |
At this point, we have an image of the Flutter logo in the middle of the screen, and a black edit icon with a grey background at the bottom. Let’s add some interactivity!
Dragging the edit icon onto the image
In this section, we are going to add drag and drop of the edit icon onto the image.
When the icon is dragged, we will show it where the finger is. This is what Flutter calls “feedback”.
And instead of the black icon on grey background at the bottom of the screen, we will show it in grey, on white background. This is what Flutter calls “childWhenDragging”.
Let’s amend EditControlsView to make it draggable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import 'package:flutter/material.dart'; enum EditType { OPACITY } class EditControlsView extends StatefulWidget { EditControlsView({Key key}) : super(key: key); @override _EditControlsViewState createState() => _EditControlsViewState(); } class _EditControlsViewState extends State<EditControlsView> { @override Widget build(BuildContext context) { return Draggable( data: EditType.OPACITY, childWhenDragging: _buildIconWhenDragging(), child: _buildIcon(), feedback: _buildIcon(), ); } Widget _buildIconWhenDragging() { return Container( padding: EdgeInsets.all(16.0), child: Icon(Icons.edit, color: Colors.grey) ); } Widget _buildIcon() { return Container( padding: EdgeInsets.all(16.0), color: Colors.grey, child: Icon(Icons.edit, color: Colors.black) ); } } |
And let’s accept the drag in PhotoView.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import 'package:complexgestures/edit_controls_view.dart'; import 'package:flutter/material.dart'; class PhotoView extends StatelessWidget { PhotoView({Key key}) : super(key: key); @override Widget build(BuildContext context) { return DragTarget( onAccept: (EditType type) { print("on accept"); }, onWillAccept: (EditType type) { print("on will accept"); return true; }, builder: ( BuildContext context, List<dynamic> accepted, List<dynamic> rejected, ) { return _buildImage(); } ); } Widget _buildImage() { return Image.network("https://upload.wikimedia.org/wikipedia/commons/1/17/Google-flutter-logo.png"); } } |
At this point, the edit icon is draggable but not much else happens, except for a print statement when it enters the image zone and another when it is dropped on the image. Let’s add the edit view!
Showing the edit view
Now that we can drag the edit icon onto the image, we can trigger the edit mode. So we need to think about tracking the edit state of the image.
We’ll do this using a singleton, accessible from all views. We create a new file image_edit_state_bloc.dart.
1 2 3 4 5 6 7 8 9 10 11 |
class ImageEditStateBloc { static final ImageEditStateBloc _singleton = new ImageEditStateBloc._internal(); factory ImageEditStateBloc() { return _singleton; } ImageEditStateBloc._internal(); } |
As a reminder, when the edit button is on the image, finger still on screen, a red overlay is added. The finger position controls the opacity of the overlay. We move the finger up for more, down for less.
We set up a model to encapsulate the edit data. There are 2 variables to track: the edit state (ie is it in progress, cancelled, or completed), and the edit value (ie the vertical position of the finger on the screen).
We add EditStateData and EditState to the same file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
class EditStateData { final EditState _editState; final double value; EditStateData(this._editState, this.value); bool isInProgress() { return _editState == EditState.IN_PROGRESS; } bool isCanceled() { return _editState == EditState.CANCELLED; } bool isCompleted() { return _editState == EditState.COMPLETED; } String toString() { return _editState.toString() + " " + value.toString(); } } enum EditState { NONE, IN_PROGRESS, COMPLETED, CANCELLED } |
We can now set up a stream of EditStateData, and methods for state changes, in ImageEditStateBloc.
We use a BehaviourSubject stream (part of rxdart plugin): it’s a broadcast stream that emits the current item to new listeners. This particular functionality is used in finishEdit().
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
import 'dart:async'; import 'package:rxdart/rxdart.dart'; class ImageEditStateBloc { static final ImageEditStateBloc _singleton = new ImageEditStateBloc._internal(); factory ImageEditStateBloc() { return _singleton; } ImageEditStateBloc._internal(); BehaviorSubject<EditStateData> _editStateDataController = BehaviorSubject<EditStateData>(); Stream<EditStateData> get editStateData => _editStateDataController.stream; void startEdit() { _editStateDataController.sink.add(EditStateData(EditState.IN_PROGRESS, 0.0)); } void editInProgress(double value) { _editStateDataController.sink.add(EditStateData(EditState.IN_PROGRESS, value)); } void finishEdit() { StreamSubscription sub; sub = editStateData.listen((editStateData) { if (!editStateData.isInProgress()) { // This should not happen. If it does, we simply cancel the editing. cancelEdit(); } else { _editStateDataController.sink.add(EditStateData(EditState.COMPLETED, editStateData.value)); } sub.cancel(); }); } void cancelEdit() { _editStateDataController.sink.add(EditStateData(EditState.CANCELLED, 0.0)); } void dispose() { _editStateDataController.close(); } } class EditStateData { final EditState _editState; final double value; EditStateData(this._editState, this.value); bool isInProgress() { return _editState == EditState.IN_PROGRESS; } bool isCanceled() { return _editState == EditState.CANCELLED; } bool isCompleted() { return _editState == EditState.COMPLETED; } String toString() { return _editState.toString() + " " + value.toString(); } } enum EditState { NONE, IN_PROGRESS, COMPLETED, CANCELLED } |
We can now call startEdit() and finishEdit() from the PhotoView. Additionally, we listen to the stream and add an EditView when edit is in progress.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import 'package:complexgestures/edit_controls_view.dart'; import 'package:complexgestures/edit_view.dart'; import 'package:complexgestures/image_edit_state_bloc.dart'; import 'package:flutter/material.dart'; class PhotoView extends StatelessWidget { PhotoView({Key key}) : super(key: key); @override Widget build(BuildContext context) { return DragTarget( onAccept: (EditType type) { ImageEditStateBloc().finishEdit(); }, onWillAccept: (EditType type) { ImageEditStateBloc().startEdit(); return true; }, builder: ( BuildContext context, List<dynamic> accepted, List<dynamic> rejected, ) { return StreamBuilder<EditStateData>( stream: ImageEditStateBloc().editStateData, initialData: EditStateData(EditState.NONE, 0.0), builder: (BuildContext context, AsyncSnapshot<EditStateData> snapshot){ if (snapshot.data.isInProgress()) { return Stack( children: <Widget> [ Center(child: _buildImage()), Center(child: EditView()), ]); } else { return _buildImage(); } }, ); } ); } Widget _buildImage() { return Image.network("https://upload.wikimedia.org/wikipedia/commons/1/17/Google-flutter-logo.png"); } } |
And we can call cancelEdit() from the Draggable, in EditControlsView.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import 'package:complexgestures/image_edit_state_bloc.dart'; [...] @override Widget build(BuildContext context) { return Draggable( data: EditType.OPACITY, childWhenDragging: _buildIconWhenDragging(), child: _buildIcon(), onDraggableCanceled: (velocity, offset) { ImageEditStateBloc().cancelEdit(); }, feedback: _buildIcon(), ); } |
Finally, we create EditView, in edit_view.dart. We use the screen height to calculate an opacity value that is between 0.0 and 1.0.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import 'package:complexgestures/image_edit_state_bloc.dart'; import 'package:flutter/material.dart'; class EditView extends StatefulWidget { EditView({Key key}) : super(key: key); @override _EditViewState createState() => _EditViewState(); } class _EditViewState extends State<EditView> { @override Widget build(BuildContext context) { return StreamBuilder<EditStateData>( stream: ImageEditStateBloc().editStateData, initialData: EditStateData(EditState.IN_PROGRESS, 0.0), builder: (BuildContext context, AsyncSnapshot<EditStateData> snapshot){ double opacity = snapshot.data.value / MediaQuery.of(context).size.height; if (opacity > 1) { opacity = 1; } return Opacity(opacity: opacity, child: Container(color: Colors.red)); }, ); } } |
At this point, well… nothing has changed visually. This is because we’re not actually tracking the finger on the screen, after the drag movement.
Tracking the finger after the drag movement
One of the limitations of the Listener widget is that it only gets pointer events if it was present in the widget hierarchy when the pointer down event was fired. It means that we need to add it to a widget always present in the hierarchy, and we need some logic so it only does something with the event when we want it to.
We are going to add it to MyHomePage, and use the stream to make sure we only do something with it when the edit state is in progress.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import 'package:complexgestures/image_edit_state_bloc.dart'; [...] class _MyHomePageState extends State<MyHomePage> { bool _editInProgress = false; @override void initState() { super.initState(); ImageEditStateBloc().editStateData.listen((editStateData) { _editInProgress = editStateData.isInProgress(); }); } @override Widget build(BuildContext context) { Widget body = Center( child: Listener( onPointerMove: (pointerMoveEvent) { if (_editInProgress) { ImageEditStateBloc().editInProgress(MediaQuery.of(context).size.height - pointerMoveEvent.position.dy); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Expanded(child: PhotoView()), EditControlsView(), ], ), ) ); return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: body, ); } @override void dispose() { ImageEditStateBloc().dispose(); super.dispose(); } } |
Now, we can see the red overlay increasing its intensity as we move the finger upwards. All we need to do is show the snackbars to confirm when the edit is completed or cancelled. We do this in MyHomePage.
Note: If you need a refresher on displaying snackbars, check out my tutorial How to show a snackbar in Flutter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
[...] class _MyHomePageState extends State<MyHomePage> { bool _editInProgress = false; BuildContext _scaffoldContext; @override void initState() { super.initState(); ImageEditStateBloc().editStateData.listen((editStateData) { _editInProgress = editStateData.isInProgress(); if (editStateData.isCanceled()) { _showCanceledMessage(); } else if (editStateData.isCompleted()) { _showCompletedMessage(editStateData.value); } }); } @override Widget build(BuildContext context) { [...] return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: new Builder( builder: (BuildContext context) { _scaffoldContext = context; return body; } ), ); } void _showCanceledMessage() { Scaffold.of(_scaffoldContext).showSnackBar(new SnackBar( content: new Text('Edit cancelled'), duration: new Duration(seconds: 2), )); } void _showCompletedMessage(double value) { Scaffold.of(_scaffoldContext).showSnackBar(new SnackBar( content: new Text('Edit completed ' + value.toString()), duration: new Duration(seconds: 2), )); } [...] } |
Voila!
What next?
In this code tutorial (full code on github), we have used Draggable, DragTarget, and Listener. Two other very important classes for gestures are GestureDetector and InkWell. A good starting point for those are How to implement a GestureDetector in Flutter and Flutter Deep Dive: Gestures.

Mobile app developer with 12 years experience. Started with the Android SDK in 2010 and switched to Flutter in 2017. Past apps range from start ups to large tech (Google) and non tech (British Airways) companies, in various sectors (transport, commercial operations, e-commerce).