Skip to content

Introducing Gestures and Animations

Adarsh Kumar Maurya edited this page Dec 16, 2018 · 2 revisions

Intro

Welcome to this last part of the tutorial, Flutter: Getting Started. In this chapter we'll introduce two features that make Flutter powerful and fun, Gestures and Animations. This is only an introduction, but still, you'll get the basics of both.

In particular, you'll see

  • How to create a GestureDetector widget and how to use its properties to leverage some of the most common gestures you can make with a smartphone or tablet.
  • Among them you'll see Tap, DoubleTap, LongPress, HorizontalDrag, and VerticalDrag.
  • In order to do that you'll use a Stack layout and a positioned element, and you'll see how to use both in your apps.
  • Finally, you'll build your first animation with an AnimationController and a CurvedAnimation.

The app you'll create in this chapter is a showcase app in which we'll build a square. You'll be able to test some gestures over the square giving feedback to the user.

For example, you'll count the number of taps, double taps, and long presses that a user makes over the square. You'll also be able to move it on the screen vertically or horizontally.

Finally, you'll add an animation over the square so that it will slowly grow in 5 seconds when the app loads. Fun isn't it? Let's get started then.

Using Gestures

From Visual Studio Code let's create a new project. We'll call it Square and save it in your favorite folder. Once the project finishes loading in the main.dart file let's delete the unnecessary code, the comments, everything in the stateful widget, except the createState call, and everything in the state except for an empty build method, the title in the MyHomePage call. Let's also change the title into Gestures and Animations, as this is what we are going to cover here. In our state we'll add three properties that will count the number of times the user performs a gesture. So we'll create an integer called numTaps, and set it to 0, and we'll do the same for the integer, numDoubleTaps, and the integer, numLongPress. At the bottom of the screen we want to write some text, the number of Taps, DoubleTaps, and LongPresses. In the build method we'll return a scaffold with an appBar whose title will be a text widget containing Gestures and Animations. Here let's add a bottomNavigationBar that will contain a material. As a color from the theme of the app, we'll choose the primaryColorLight. As a child we'll add up adding, as we want to have some space before we write our text, and its value will be an EdgeInsets.all of 15.0.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gestures and Animations',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Gestures and Animations"),
      ),
      bottomNavigationBar: Material(
        color: Theme.of(context).primaryColorLight,
        child: Padding(
          padding: EdgeInsets.all(15.0),
          child: Text(
            "Taps $numTaps -Double Taps: $numDoubleTaps - Long Presses: $numLongPress",
            style: Theme.of(context).textTheme.title,
          ),
        ),
      ),
    );
  }
}

The child of the padding is actually our text with Taps $numTaps, then Double Taps with $numDoubleTaps, and Long Presses with $numLongPress. For the style we'll use the theme of our context and choose the textTheme.title style. Now we need to draw the square. In order to-do that we'll use two widgets, stack and positioned.

Stack Layout

A stack is a layout widget that positions its children relative to the edges of its box.

  • Until now, we've seen the column layout that places its children vertically,
  • The row layout that places them horizontally, and
  • The ListView that creates a scrolling list of items.

The stack layout allows you to place its children relative to the border of the stack, so generally speaking, this is ideal when you want to overlay widgets on top of each other, but also when you want to decide exactly how to position an element inside another element.

Positioned Widget

Inside a stack you can place a positioned widget that has a top, bottom, left, and right properties, so that you can control exactly how an element is placed inside a stack.

Let's see all that in action. First, let's create two properties, a posX and a posY for the horizontal and vertical position of our square. Both are doubles. Let's give them a value of 0.0 for now. Let's also define another double for the boxSize property. This will be the width and height of the square. Let's set it to 150.0 for now. As a child in the body of the scaffold we'll place a stack. This has a children property in which we will only put a Positioned element that will have a left property with a value of posX and the top with a value of posY.The child will be a Container with width of boxSize and a height of boxSize as well. Let's give it a decoration. It will have a BoxDecoration with a color property of Colors.red. We want this square to be very visible. Now before trying this out I'd like to place the square in the center of the screen, so let's create a center method that will return void and take a BuildContext as an argument. This is because we need to know the size of the screen, and for that we need the context. For the horizontal positioning we will get the width of the screen using the MediaQuery.of method. This gives you the size and orientation of your current app. We'll pass it our context and get the size and width of our app space. We'll divide it by two and subtract half the size of the box. We'll do exactly the same for the vertical positioning, but we'll also subtract 30 because some of the vertical space is already taken for the title and the bottom text. Then we'll call the setState method, setting the posX and posY properties of our state. Now when we build our UI if posX equals 0.0, that is, when our app loads for the first time, we'll call the center method, passing the context.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gestures and Animations',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;

  double posX = 0.0;
  double posY = 0.0;
  double boxSize = 150.0;

  @override
  Widget build(BuildContext context) {
    if (posX == 0.0) {
      center(context);
    }
    return Scaffold(
      appBar: AppBar(
        title: Text("Gestures and Animations"),
      ),
      body: Stack(children: <Widget>[
        Positioned(
          left: posX,
          top: posY,
          child: Container(
            width: boxSize,
            height: boxSize,
            decoration: BoxDecoration(
              color: Colors.red,
            ),
          ),
        ),
      ]),
      bottomNavigationBar: Material(
        color: Theme.of(context).primaryColorLight,
        child: Padding(
          padding: EdgeInsets.all(15.0),
          child: Text(
            "Taps $numTaps -Double Taps: $numDoubleTaps - Long Presses: $numLongPress",
            style: Theme.of(context).textTheme.title,
          ),
        ),
      ),
    );
  }

  void center(BuildContext context) {
    posX = (MediaQuery.of(context).size.width / 2) - boxSize / 2;
    posY = (MediaQuery.of(context).size.width / 2) - boxSize / 2 - 30.0;
    setState(() {
      posX = posX;
      posY = posY;
    });
  }
}

Okay, let's see how it looks. Good. A nice visible red square.

Gesture Detector

Now let's react to a few gestures of our user. In the body of the Scaffold we'll use a GestureDetector. As the name implies, a GestureDetector is a widget that detects gestures. It works like this.

GestureDetector(
    onTap:(){},
    onDoubleTap: (){},
    onLongPress: (){}
    ...
)

In the body of your layout you insert a GestureDetector. This widget has properties that respond to gestures of our user. In the screen you can see a few examples, onTap, onDoubleTap, and onLongPress. Inside each of those gesture properties you can add your code. Generally, what you'll do is change the state of the widget, but you are certainly not limited to that.

So back to our code, let's enclose the stack into a GestureDetector. The stack will become the child of the GestureDetector, and here we'll start adding Gesture properties. We'll begin with the onTap, and here we'll call the setState() method incrementing the numTaps property each time the tap gesture is detected. Simple as that. Okay, let's try this out.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gestures and Animations',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;

  double posX = 0.0;
  double posY = 0.0;
  double boxSize = 150.0;

  @override
  Widget build(BuildContext context) {
    if (posX == 0.0) {
      center(context);
    }
    return Scaffold(
      appBar: AppBar(
        title: Text("Gestures and Animations"),
      ),
      body: GestureDetector(
          onTap: () {
            setState(() {
              numTaps++;
            });
          },
          child: Stack(children: <Widget>[
            Positioned(
              left: posX,
              top: posY,
              child: Container(
                width: boxSize,
                height: boxSize,
                decoration: BoxDecoration(
                  color: Colors.red,
                ),
              ),
            ),
          ])),
      bottomNavigationBar: Material(
        color: Theme.of(context).primaryColorLight,
        child: Padding(
          padding: EdgeInsets.all(15.0),
          child: Text(
            "Taps $numTaps -Double Taps: $numDoubleTaps - Long Presses: $numLongPress",
            style: Theme.of(context).textTheme.title,
          ),
        ),
      ),
    );
  }

  void center(BuildContext context) {
    posX = (MediaQuery.of(context).size.width / 2) - boxSize / 2;
    posY = (MediaQuery.of(context).size.width / 2) - boxSize / 2 - 30.0;
    setState(() {
      posX = posX;
      posY = posY;
    });
  }
}

Now when you tap over the square the number of taps that we see at the bottom of the screen grows. Notice that if you tap outside of the square nothing happens. This is exactly what we wanted to achieve. Okay, let's also add the doubleTap and the longPress. We'll copy the code of the onTap property, and by the way, with Visual Studio Code if you want to copy some code and paste it below you can select the code you want to copy and then press Alt+Shift+arrow down, and this will paste the code just below your selection. The second gesture will be onDoubleTap, and this will increment the numDoubleTaps value, and the third one will be onLongPress, which, quite predictably, will increment the numLongPress property. Okay, let's try this out again.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gestures and Animations',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;

  double posX = 0.0;
  double posY = 0.0;
  double boxSize = 150.0;

  @override
  Widget build(BuildContext context) {
    if (posX == 0.0) {
      center(context);
    }
    return Scaffold(
      appBar: AppBar(
        title: Text("Gestures and Animations"),
      ),
      body: GestureDetector(
          onTap: () {
            setState(() {
              numTaps++;
            });
          },
          onDoubleTap: () {
            setState(() {
              numDoubleTaps++;
            });
          },
          onLongPress: () {
            setState(() {
              numLongPress++;
            });
          },
          child: Stack(children: <Widget>[
            Positioned(
              left: posX,
              top: posY,
              child: Container(
                width: boxSize,
                height: boxSize,
                decoration: BoxDecoration(
                  color: Colors.red,
                ),
              ),
            ),
          ])),
      bottomNavigationBar: Material(
        color: Theme.of(context).primaryColorLight,
        child: Padding(
          padding: EdgeInsets.all(15.0),
          child: Text(
            "Taps $numTaps -Double Taps: $numDoubleTaps - Long Presses: $numLongPress",
            style: Theme.of(context).textTheme.title,
          ),
        ),
      ),
    );
  }

  void center(BuildContext context) {
    posX = (MediaQuery.of(context).size.width / 2) - boxSize / 2;
    posY = (MediaQuery.of(context).size.width / 2) - boxSize / 2 - 30.0;
    setState(() {
      posX = posX;
      posY = posY;
    });
  }
}

If we double tap on the square the text at the bottom is updated, and the same happens if you press on the square for a few seconds.

Okay, let's move the square. There are two gestures that we'll use for this example. The onVerticalDragUpdate and the onHorizontalDragUpdate. Let's begin with the vertical drag. The syntax is the same as the other gestures, except that when one of those is triggered a DragUpdateDetails is passed to your function. What we want to do here is updating the vertical position of our square. As you might recall, this is contained in a variable called posY, so let's call the setState method.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gestures and Animations',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;

  double posX = 0.0;
  double posY = 0.0;
  double boxSize = 150.0;

  @override
  Widget build(BuildContext context) {
    if (posX == 0.0) {
      center(context);
    }
    return Scaffold(
      appBar: AppBar(
        title: Text("Gestures and Animations"),
      ),
      body: GestureDetector(
          onTap: () {
            setState(() {
              numTaps++;
            });
          },
          onDoubleTap: () {
            setState(() {
              numDoubleTaps++;
            });
          },
          onLongPress: () {
            setState(() {
              numLongPress++;
            });
          },
          onVerticalDragUpdate: (DragUpdateDetails value) {
            setState(() {
              double delta = value.delta.dy;
              posY += delta;
            });
          },
          onHorizontalDragUpdate: (DragUpdateDetails value) {
            setState(() {
              double delta = value.delta.dx;
              posX += delta;
            });
          },
          child: Stack(children: <Widget>[
            Positioned(
              left: posX,
              top: posY,
              child: Container(
                width: boxSize,
                height: boxSize,
                decoration: BoxDecoration(
                  color: Colors.red,
                ),
              ),
            ),
          ])),
      bottomNavigationBar: Material(
        color: Theme.of(context).primaryColorLight,
        child: Padding(
          padding: EdgeInsets.all(15.0),
          child: Text(
            "Taps $numTaps -Double Taps: $numDoubleTaps - Long Presses: $numLongPress",
            style: Theme.of(context).textTheme.title,
          ),
        ),
      ),
    );
  }

  void center(BuildContext context) {
    posX = (MediaQuery.of(context).size.width / 2) - boxSize / 2;
    posY = (MediaQuery.of(context).size.width / 2) - boxSize / 2 - 30.0;
    setState(() {
      posX = posX;
      posY = posY;
    });
  }
}

Here first we'll read the length of the movement that was performed by the user, so we create a double called delta, and from the value DragUpdateDetails from the delta we'll read the dy property, which is the vertical movement. This can be positive or negative, so our square will be able to move up if it's negative and down if it's positive. So posY will be posY plus delta. We'll repeat exactly the same approach for the horizontal drag. The only difference is that we'll update the posX property. Okay, let's see how this is working. Now if you press on the square and then drag it up or down the square follows your movement, and the same happens if you move it left or right. Well done. Now let's add an animation to our square.

Using Animations

What we want to do now is make our square appear slowly over a time of 5 seconds and grow until it reaches its full size. In order to do that we'll need two objects, an AnimationController and that CurvedAnimation.

// Create an AnimationController
controller = AnimationController(duration: const Duration(milliseconds: 500), vsync: this);

// Create an Animation
animation = CurvedAnimation(parent: controller, curve: Curve.easeInOut);

// Add a Listener
animation.addListener((){
    setState((){});
});

// Start the animation
controller.forward();
  • An AnimationController is an object that generates a new value whenever the hardware is ready for a new frame. An AnimationController generates numbers from 0.0 to 1.0 in the time you specify.

    • In the example you see on the screen, we are creating an animation controller with a duration of 500 milliseconds or half a second. The vsync property makes sure that if an object is not visible it does now waste system resources.
  • Next, we create a CurvedAnimation. A CurvedAnimation defines the animation's progress as a nonlinear curve. So in the example you see an easeInOut curve. It's an animation that starts slowly, speeds up, and then ends slowly.

  • The addListener method of the animation is called whenever the value of the animation changes, and in there we'll call the setState method to change some properties of the UI.

  • Finally, the forward() method makes the animation begin.

Now where should we place this code? There are several methods you can use during the lifecycle of a widget.

Stateful Widget Lifecycle
 initState
   |
 build <- setState()
   |
 dispose

In this example we'll use three of them.

  • The initState method is called by the framework when a state is created. This method is perfect when you want to perform initialization, as it is called only once.

  • We have already used the build method several times. It's worth noting that each time you call the setState method the build is called.

  • At the end of the lifetime, we can override the dispose method.This is great when you need to free resources from the system.

So from this description you probably guessed that we need to set up our animation in the initState method.

First, let's create the properties that will contain the animation and the controller. We'll call the Animation of type double animation, and the AnimationController controller. Now let's override the initState method. After calling the super.initState we'll create our controller that will be an AnimationController with a duration of 5000 milliseconds, which is 5 seconds, and a vsync of this. You may notice we get an error here.

...

class _MyHomePageState extends State<MyHomePage> {
  Animation<double> animation;
  AnimationController controller;

  ...

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
        duration: const Duration(milliseconds: 5000), vsync: this);
  }

That is because we need to add the with single ticker provider state mixing close to our state.

A little bit of theory here.

Mixin

In object oriented programming languages a mixin is a class that contains methods that can be used by other classes without having to be the parent class of those other classes. That's why we use the with clause in Flutter because in a way we are including the class, not inheriting from it.

The SingleTickerProviderStateMixin provides one ticker. A ticker calls its callback once per animation frame. Now let's create the animation that will be a CurvedAnimation whose parent is the controller, and the curve is the easeInOut curve. Next, we'll add the listener. We know that the animation will havevalues from 0 to 1, so at 0 our square will be invisible, and at 1 it will be at its full size. So let's set the initial boxSize value to 0, and let's create another variable called fullBoxSize that will be final with a value of 150.0. In the addListener we'll call the setState method, and here we'll set the changing boxSize, so we can just multiply the size of the square and the value of the animation. Now we also need to center the square while its size is changing, and we can do that by calling the center method.Finally, in order to actually start the animation we'll call the controller.forward method.

...
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  Animation<double> animation;
  AnimationController controller;

  int numTaps = 0;
  int numDoubleTaps = 0;
  int numLongPress = 0;

  double posX = 0.0;
  double posY = 0.0;

  double boxSize = 0.0;
  final double fullBoxSize = 150.0;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
        duration: const Duration(milliseconds: 5000), vsync: this);

    animation = CurvedAnimation(
      parent: controller,
      curve: Curves.easeInOut,
    );

    animation.addListener(() {
      setState(() {
        boxSize = fullBoxSize * animation.value;
      });
      center(context);
    });

    controller.forward();
  }
 @override
  void dispose() {
    super.dispose();
  }
...

Let's also clean up the resources overriding the dispose method. Here we'll just call the dispose method of the controller, and that's it. Let's have a look at the result. As you can see, when the app loads the square appears and gets to it's full size in 5 seconds. This was actually the last demo for this tutorial. Let's briefly recap what we've seen here.

Summary and Takeaways

In this chapter you had an introduction to gestures and animations. These topics would require a full tutorial on their own, but I hope you got the idea of the potential of using gestures and animations into your projects. - - You've seen how to enclose a widget into a GestureDetector and how to use a few gestures in your app, like tap, doubleTap, longPress, and horizontal and verticalDrag.

  • You've seen the Stack layout in which elements are positioned relative to the border of the stack.
  • We've talked about stateful widgets lifecycle and seen three methods; initState(), which is triggered once before the build() method, and you've used the dispose() method to clean up resources.
  • Finally, you've created a simple animation and used the addListener() method to update the state of an object during an animation, and the forward() method to begin the animation itself.

Congratulations. You have now finished this introductory tutorial to Flutter. I hope you now believe, like I do, that Flutter has a huge potential in the space of mobile development. There would be so many other topics to cover. At this time, the resource I recommend if you want to dive deeper into Flutter is the official guide at flutter.io.

Great. Thanks for following me up to this point. I hope you enjoyed this tutorial, and wish you'll make several successful apps with Flutter. Happy coding. Thank you!