Creating Custom Gestures in Flutter

Learn how to add custom gestures in your Flutter app by working on a fun lock-picking app. By Alejandro Ulate Fallas.

Login to leave a rating/review
Download materials
Save for later
Share

Learn how to add custom gestures in your Flutter app by working on a fun lock-picking app.

Gestures and motion are a great way to catch a user’s attention in your app. Typically, you use them to provide a more natural interaction between the person using your app and, well… your app! Simple taps can only do so much, and adding different custom gestures can significantly improve the user experience.

In this tutorial, you’ll:

  • Learn how GestureDetector works and the common uses for it.
  • Learn the chain of responsibility design pattern.
  • Add common gestures expected on mobile apps.
  • Use RawGestureDetector to recognize custom gestures.
  • Create a custom rotation gesture.
  • Combine both common and custom gestures.
Note: This tutorial assumes you have intermediate knowledge of stateful and stateless widgets. To learn more about them, check out the Getting Started with Flutter tutorial.

Getting Started

Download the starter project by clicking Download Materials at the top or bottom of the tutorial.

You’ll work on Keymotion, a mischievous game that uses custom gestures to test the user’s lock-picking skills. The game’s goal is simple: Gather as many keys as possible before the time runs out. The players try to guess the combination of a lock by rotating it with two fingers. A combination is a specific position that the player needs to rotate the lock to. The game also has bonus keys you can collect by performing secret gestures in the app. You’ll add this feature later in this tutorial.

Open the starter project in VS Code 1.61 or later. You can also use Android Studio, but you’ll have to adapt the instructions below as needed.

Use a recent version of Flutter, 2.5 or above. VS Code should show a notification prompting you to click into it to get the dependencies.

If VS Code doesn’t get the dependencies, then download them by opening pubspec.yaml and clicking the get package icon on the top-right corner or by running the command flutter pub get from the terminal.

Here’s a quick rundown of how the project is set up:

  • main.dart: Standard main file required for Flutter projects.
  • domain.dart: Contains the game logic and corresponding class definitions.
  • app: A folder with the app widget and also a helper file with colors defined by the brand guidelines.
  • presentation: Contains different folders that build Keymotion’s UI:
    • pages has all the pages.
    • widgets contains all the reusable widgets.
    • dialogs has all the game’s dialog.
    • gestures contains RotateGestureRecognizer, where you’ll place the custom rotation logic.

Build and run the starter project using the emulator of your preference or a mobile device. At this point, you’ll see the following:

Keymotion app start screen

As you can see, this is your main game menu. Tap Play to see what the game screen looks like:

Keymotion app showing combination lock and hint

At the top of the screen, you’ll see the GIVE UP button. When tapped, it’ll open a dialog to confirm your decision to leave the game.

The game screen has a big lock with a small number indicating the number of combinations the user has to pick. Tap it, and you’ll see the core rotate. You’ll convert this to the custom rotation gesture later in this tutorial.

At the bottom, you’ll also see the game’s stat bar. On the left, it has the number of keys, indicating the number of locks picked. On the right, it has a countdown timer with the time left to discover the combinations. Once the timer is up, it’ll show the Game Over dialog, allowing the player to start a new game or go back to the game menu.

For now, tap GIVE UP and confirm your decision to return to the game menu.

Learning About Gestures

Gestures are a great way to enhance the interactivity between the app and users. Normally, users expect the app to support at least the basic gestures, depending on the type of the app. For example, if you use an app that has a list in it, you might expect to be able to:

  • Tap the list item to see details.
  • Long-press the list item to move it around.
  • Swipe the item to dismiss or delete it.

Of course, some gestures have variations in their usage. An example is swiping an item, which is used for revealing different actions or deleting.

Navigation Gestures are another great example. Many new devices have gestures that allow the user to navigate between screens without hardware buttons.

Basic and Complimentary Gestures

Gestures are also classified as basic or complimentary. Basic gestures are the simplest user interactions, like tapping an item or swiping to scroll. Complimentary gestures are more complex and often combine basic gestures. They can also be a different interpretation of a basic gesture’s user interaction.

Here are some of basic and complementary gestures that users expect from an app:

Illustration of basic and complementary gestures

As you can see from the image above, a complimentary gesture could be long-pressing an item, which would be a variation on the basic tap gesture.

Another example of a complementary gesture is rotation, which is a variation on the basic scale gesture. Both are performed using two fingers; in fact, it’s common to add both gestures so users can rotate an element on the screen while zooming in or out.

Now that you’re familiar with some of the different types of gestures, it’s time to learn about Flutter’s gestures library.

Introducing GestureDetector

Out of the box, Flutter’s gestures library does a great job understanding basic and complementary gestures. It uses a combination of widgets and interpreter classes called recognizers to provide a robust API around gestures.

Widgets are responsible for receiving different types of user input. When the user interacts with the widget on the screen, it generates an input event that needs interpretation. Recognizers are in charge of interpreting those user inputs. Some examples of said inputs are tapping a button or scrolling up.

You might wonder how to create a widget that receives the correct user input, but the answer is: You don’t need to! Flutter already has a widget called GestureDetector that allows you to detect common gestures.

In fact, you’ve probably already interacted with a widget and recognizer combo. Widgets like InkWell or ElevatedButton use GestureDetector under the hood to recognize gestures like tap or long-press.

Keymotion also has a GestureDetector added to detect tap gestures to rotate the lock. Open lib/presentation/widgets/rotating_lock_core.dart and pay close attention to build in the widget’s state:

Widget build(BuildContext context) {
 // 1.
 return GestureDetector(
    // 2.
    onTap: () {
      setState(() {
        // 3.
        currentAngle += 2 * math.pi / 180;
        final angleDegrees = (currentAngle * 180 ~/ math.pi).abs();

        context.read<Game>().tryCombination(angleDegrees);
      });
    },
    child: Stack(...)
    ),
  );
}

Here’s a quick rundown of what’s going on in the code above:

  1. GestureDetector handles all interactions and gestures done within the bounds of Stack.
  2. In this case, it recognizes the tap gesture by implementing onTap in the widget. Then, the app uses setState to update the current angle of rotation and ensure the widget is rebuilt.
  3. Within setState, you’re updating the current rotation angle for the lock by adding a turn. In this case, a turn is an arbitrary value calculated in radians. At the same time, you’re converting the radians to degrees. This is what the game understands as a combination.

    After the current angle is updated, the Game object checks to see whether the combination solves the lock or not. If it’s a valid combination, then it resets the currentAngle to 0 so the user can start from scratch and try other combinations.

    Note: If you want to know what context.read() does, check out the State Management With Provider tutorial, as it explains provider in detail.

Alright, you’ve read for quite a while — now it’s time for some coding!

Implementing a Common Gesture

Build and run the project, then start a new game. Try to guess the first lock combination by tapping the lock’s core a bunch of times. As you can see, the player has to tap the lock to rotate it. Instead, the user should be using two fingers to rotate the lock. You’ll replace this gesture later on in the tutorial.

Another issue is that you can’t rotate backward if you want to retry any previous combinations. This makes all past combinations impossible to get. You’ll fix that now by using double-tap gestures to signify that the lock should rotate backwards.

A double-tap gesture happens when the user taps the screen in the same location twice in quick succession. Flutter establishes that these two-tap events have to be within a time of 300 milliseconds to recognize them as a double-tap gesture.

Open lib/presentation/widgets/rotating_lock_core.dart again and copy this code in line 70, above the child property:

// 1
onDoubleTap: () {
  setState(() {
    // 2
    currentAngle -= 2 * math.pi / 180;
    final angleDegrees = (currentAngle * 180 ~/ math.pi).abs();

    final isCorrect = context.read<Game>().tryCombination(angleDegrees);

    if (isCorrect) currentAngle = 0;
  });
},

After this, your build method should look like this:

Widget build(BuildContext context) {
  // TODO: Convert to KeyMotionGesture
  return GestureDetector(
    onTap: () { ... },
    // 1
    onDoubleTap: () {
      setState(() {
        // 2
        currentAngle -= 2 * math.pi / 180;
        final angleDegrees = (currentAngle * 180 ~/ math.pi).abs();

        final isCorrect = context.read<Game>().tryCombination(angleDegrees);

        if (isCorrect) currentAngle = 0;
      });
    },
    child: Stack( ... ),
  );
}

Here’s a breakdown of what the new code is doing:

  1. First, you’re adding a new gesture to the parent GestureDetector. This time, you’re using onDoubleTap to detect double taps.
  2. Next, you’re using the same math that you used in the onTap callback but subtracting from the angle instead of adding to it. This will emulate the lock rotating backwards instead of forwards after a double tap.

Hot restart the app, and when you start a new game, double-tap the lock to see the rotation go counterclockwise. Remember the 300-millisecond timeout when trying it!

Keymotion app lock core pointer turning clockwise then counterclockwise

Wow, adding that gesture was quick and easy!

Creating a Custom Gesture

As you’ve already seen, Flutter provides interpreters for many gestures: GestureRecognizers. GestureDetector can identify gestures because it has recognizers defined for them.

Gesture recognizers are responsible for defining how a gesture behaves. Most of the time, recognizers involve math calculations to define pointers and locations. They also provide callbacks on different situations like starting or ending a gesture. For example, ScaleGestureRecognizer can identify when the user attempts scale gestures. It has callbacks when the gesture starts, updates or ends. But when does this gesture actually start or end? And also, what triggers an update callback?

In ScaleGestureRecognizer‘s case, the gesture starts when two fingers touch the screen. Then, each time one or both fingers move around, that corresponds to an update of the gesture, which includes angle and rotation changes. Finally, when the user lifts one or both fingers from the screen, the gesture ends.

You can also create your own gesture recognizer by extending GestureRecognizer. You’d then need to override the appropriate pointer-events to match the behavior you expect. Chances are you won’t need to define your own GestureRecognizer from scratch since Flutter comes packed with all of the recognizers you’d normally need. If you wanted to detect something like the user drawing a circle or star pattern, you’d need to create your own GestureRecognizer.

In Keymotion’s case, you’ll use ScaleGestureRecognizer as a base for the rotation gesture you’re going to create. The new recognizer will be called RotateGestureRecognizer. Extending from ScaleGestureRecognizer helps you delegate most of the heavy lifting to an already tested recognizer.

Once completed, RotateGestureRecognizer should be able to recognize two fingers pressing the screen and rotating clockwise or counterclockwise.

The gesture looks like this:

Rotation Gesture Demonstration

Time to start coding! Open lib/presentation/gestures/rotate_gesture_recognizer.dart, and replace // TODO: Finish RotateGestureRecognizer implementation with the code below:

// 1
double previousRotationAngle = 0;
//2
GestureRotationStartCallback? onRotationStart;
// 3
GestureRotationUpdateCallback? onRotationUpdate;
// 4
GestureRotationEndCallback? onRotationEnd;

// TODO: Bypass scale start gesture events

// TODO: Bypass scale update gesture events

// TODO: Bypass scale end gesture events

The code above defines how the recognizer should behave and the callbacks it'll have while processing the gesture. Here's a step-by-step explanation of what this code means:

  1. previousRotationAngle represents the last rotation angle the gesture recognized. This property helps calculate the acceleration between each change update callback.
  2. onRotationStart is the first callback for the gesture recognizer. It behaves in the same way as ScaleGestureRecognizer's onStart callback.
  3. onRotationUpdate can happen many times between onRotationStart and onRotationEnd. It provides a callback for rotation angle and acceleration updates.
  4. onRotationEnd triggers when the user lifts one or both fingers from the screen.

For RotateGestureRecognizer to provide custom callbacks, you'll need to override certain behaviors from the parent ScaleGestureRecognizer. This also allows you to inform gesture detectors on relevant information about the gesture itself.

Now, replace // TODO: Bypass scale start gesture events with the code below:

@override
GestureScaleStartCallback? get onStart => _scaleStarts;

void _scaleStarts(ScaleStartDetails details) {
  onRotationStart?.call(RotationStartDetails());
}

By overriding onStart, you're bypassing ScaleGestureRecognizer's behavior. This way, you're only passing relevant details about the custom gesture's start. RotationStartDetails defines what the details contain. But, in this case, you'll use the default empty constructor.

Next, you need to do the same for the update callback. Copy the code below, and replace // TODO: Bypass scale update gesture events with it.

@override
GestureScaleUpdateCallback? get onUpdate => _scaleUpdates;

void _scaleUpdates(ScaleUpdateDetails details) {
  onRotationUpdate?.call(
 RotationUpdateDetails(
      rotationAngle: details.rotation,
      acceleration: (details.rotation - previousRotationAngle).abs(),
    ),
  );
  previousRotationAngle = details.rotation;
}

Here's where the magic happens! You've overridden ScaleGestureRecognizer's onUpdate callback and provided your own implementation. In this case, you're taking advantage of ScaleUpdateDetails to define your own rotation and acceleration. ScaleUpdateDetails calculates the current rotation in radians using trigonometry functions, which you'll need now.

Next, you create a new RotationUpdateDetails instance to provide insights about rotation angle and acceleration through your custom callback onRotationUpdate.

Now, you'll bypass onEnd as the final step. Replace // TODO: Bypass scale end gesture events with the code below:

@override
GestureScaleEndCallback? get onEnd => _scaleEnds;

void _scaleEnds(ScaleEndDetails details) {
  onRotationEnd?.call(RotationEndDetails());
}

With this, you're overriding onEnd to notify handlers that the gesture has ended and it's ready to work with the next gesture.

Build and run the project. Right now, no changes are visible.

Explaining the Chain of Responsibility Pattern

One more thing you should know about gestures is how Flutter delegates user input.

Time for a pop quiz: In the code below, after tapping the red Container from the widget once, what does the system print?

Widget build(BuildContext context) {
 return GestureDetector(
    onTap: () => print('Tapped parent!'),
    onDoubleTap: () => print('Double tapped parent!'),
    child: GestureDetector(
      onTap: () => print('Tapped child!'),
      child: Container(width: 200, height: 200, color: Colors.red),
    ),
  );
}

Have your answer? Yes? Well, then, the correct answer is Tapped child!. This is because of a design pattern called chain of responsibility. Chain of responsibility is a behavioral design pattern that lets you pass events along a chain of handlers.

Upon receiving an event, each handler decides whether to process the event or pass it to the next handler in the chain, hence the name "chain of responsibility".

Diagram explaining chain of responsibility

In the quiz, the tap gesture occurs in the red Container, then it looks up the widget tree to find the first widget that can handle the event. The parent of the red Container, which is the child GestureDetector, has an onTap definition that handles the event and then stops propagating.

Here's another pop quiz for you: What does the system print after double-tapping the red Container from the widget in the code above?

Yes! The first widget that can handle the double-tap gesture is the parent GestureDetector, so the app would print Double tapped parent!.

Understanding the chain of responsibility design pattern will help you throughout your adventure of building custom gestures because it's the most used pattern when responding to user input events. As a side bonus, it'll also help you avoid lots of headaches when combining common and custom gestures.

Understanding RawGestureDetector

Remember when you read about how awesome GestureDetector is and how you've already interacted with it? Well... the same thing happens with RawGestureDetector. Underneath, GestureDetector uses it to interpret common gestures in a more "raw" interaction.

RawGestureDetector can wrap a widget the same way GestureDetector does, but it requires you to define what gestures you want. It does this by accepting a gesture map of the recognizers you want to accept.

If you're wondering when to use GestureDetector or RawGestureDetector, the answer is simple. If you're looking to use built-in gestures, then using GestureDetector is enough. But if you're creating your own recognizers, then you're better off using RawGestureDetector.

Please note that working with RawGestureDetector can result in code duplication. This is why you should wrap it with another widget around your app. For example, you'll use KeymotionGestureDetector to wrap RawGestureDetector. This makes recognizing the rotate gesture easier to add and maintain in different parts of the app.

Open lib/presentation/widgets/keymotion_gesture_detector.dart, and look at RawGestureDetector:

Widget build(BuildContext context) {
  return RawGestureDetector(
    child: child,
    gestures: <Type, GestureRecognizerFactory>{
      // TODO: Add rotate gesture recognizer.
      // TODO: Add secret key bonus.
    },
  );
}

As you can see, the gestures property is a map of GestureRecognizer to GestureRecognizerFactory.

Copy the code below and replace the comment // TODO: Add rotate gesture recognizer.:

RotateGestureRecognizer:
 GestureRecognizerFactoryWithHandlers<RotateGestureRecognizer>(
  () => RotateGestureRecognizer()
    ..onRotationStart = onRotationStart
    ..onRotationUpdate = onRotationUpdate
    ..onRotationEnd = onRotationEnd,
  (instance) {},
),

First, you're registering the type RotateGestureRecognizer as a key in gestures. This helps RawGestureDetector redirect interactions to the recognizer. It also determines if the widget should process the interaction. Then, you're creating a new GestureRecognizerFactoryWithHandlers for your custom gesture recognizer. This is a factory for creating gesture recognizers that delegates to callbacks.

At this point, KeymotionGestureDetector can intercept rotation gestures, but you still need to add it to your game. Open lib/presentation/widgets/rotating_lock_core.dart and replace build with the following:

@override
Widget build(BuildContext context) {
  final radius = MediaQuery.of(context).size.height / 4;
  // 1.
  return KeymotionGestureDetector(
    onRotationUpdate: (details) {
      setState(() {
        // 2.
        final angleDegrees = (details.rotationAngle * 180 ~/ math.pi).abs();
        currentAngle = details.rotationAngle;
        // 3.
        if (details.acceleration <= 0.0025) {
          final isCorrect = context.read<Game>().tryCombination(angleDegrees);

          if (isCorrect) currentAngle = 0;
        }
      });
    },
    // TODO: Add secret key gesture.
    child: Stack(
      alignment: Alignment.center,
      children: [
        Transform.rotate(
          angle: ((currentAngle * 180.0) / math.pi) / 10,
          child: ConstrainedBox(
            constraints: BoxConstraints.tightForFinite(
              height: radius,
            ),
            child: Image.asset('assets/images/lock_core.png'),
          ),
        ),
        AnimatedOpacity(
          opacity: widget.combinationsLeft == 0 ? 0.4 : 1,
          duration: const Duration(milliseconds: 200),
          child: Column(
            children: [
              Text(
                widget.combinationsLeft.toString(),
                style: const TextStyle(
                  fontSize: 24,
                  color: KeyMotionColors.tint1,
                ),
              ),
              const SizedBox(height: 8),
              const Icon(
                FeatherIcons.shield,
                color: KeyMotionColors.tint1,
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

Here's an overview of the changes you just made:

  1. You replaced GestureDetector with your own custom KeymotionGestureDetector, which now handles rotation gestures.
  2. You're now listening for the onRotationUpdate callback. Since the event details contain the rotation angle in radians, you need to convert it to degrees before trying the combination. You're also updating currentAngle to match the rotation provided.
  3. Finally, you're checking that the acceleration doesn't surpass an arbitrary tolerance value. This forces the user to rotate slowly to verify the combination.

Add the following import to the top of rotating_lock_core.dart:

import 'keymotion_gesture_detector.dart';

And there you have it! You're now using the awesome RotateGestureRecognizer you built! Build and run the project. At this point, you'll see the following:

Keymotion lock core rotating smoothly until the lock opens

You might notice the lock makes a quick jump when making two to four complete turns. This happens because the formula used for calculating the rotation needs tweaking. It doesn't think your fingers can return to the initial position. So, when this occurs, it thinks the gesture started over and ends in jumping rotations.

If you need to be exact when rotating, you can create your own formula to define rotations. In this case, it works fine since the combinations created by Keymotion don't need that much rotation.

Note: If you're using the simulator for iOS, you can use the Option key to simulate two fingers and Shift to move around said fingers. Then press and hold — while pressing Alt — and you'll be able to perform rotation gestures.

Combining Common and Custom Gestures

The last feature you'll add to Keymotion is a bonus system where a user can unlock a free key by double tapping the lock. Double-tapping the lock should award the player a bonus key each time the game starts. This gives an edge to players who are paying attention to the gestures in the game.

OK, enough about the concept, now you'll add this feature.

Taking advantage of the fact that you've already built a custom gesture detector widget, open lib/presentation/widgets/keymotion_gesture_detector.dart and replace // TODO: Add secret key bonus. with the following code:

DoubleTapGestureRecognizer:
 GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
  () => DoubleTapGestureRecognizer()..onDoubleTap = onDoubleTap,
  (instance) {},
),

This adds another entry to RawGestureDetector's map of gestures. Now, open lib/presentation/widgets/rotating_lock_core.dart again and replace // TODO: Add secret key gesture. with the following code:

onDoubleTap: () => context.read<Game>().foundSecretKey(),

Build and run. You can now unlock keys by rotating the lock with two fingers or get the bonus key by double-tapping the lock.

Secret Double Tap for bonus key demo

Aaaaaand voilá! Keymotion is now up and running, and it's all thanks to you.

Where to Go From Here?

You can download the completed project files by clicking Download Materials at the top or bottom of the tutorial.

In this tutorial, you took an app that could only recognize single taps and updated it to include double-taps and rotation — great job!

If you want to learn more about design patterns, check out Flutter Design Patterns. It contains good explanations for each one of them.

Want to see what basic and complementary gestures GestureDetector supports? Check out the documentation for a full list of them.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!