Welcome aboard! Join me on part 3 of the Flutter Web Series of Codelabs. If you've come from the previous codelabs - great job in making it this far. If not, here's part 1 and part 2 for you to catch up.

NOTE: THIS IS NOT A BEGINNER CODELAB

(If you're looking for more beginner content, please visit some of my other codelabs here). If you're up for the challenge, then proceed.

In Part 3, you will spruce up your Flutter Web app by implementing animations, elevating your web app and adding some flare! In this codelab you'll learn the following:

Let's proceed!!

App

Animations in Flutter can be applied out-of-the-box thanks to a number of built-in widgets that provide animation capabilities to your apps, and can be divided into two main categories: Implicit and Explicit animations.

With implicit animations, you get the convenience of adding motion and visual effects with pre-packaged widgets that manage the animation effects for you - you trade control for convenience.

With explicit animations, you have more fine-grained control over how your animation executes, which gives you a lot of flexibility (control the duration, the curve, the type of animation, delay, interval) via AnimationControllers.

In this app, we'll use explicit animations, but using a very handly package called flutter_animations, a performant library that makes it simple to add explicit animations - a good balance between convenience and control.

Start by importing the flutter animate package by executing the following command in the command line:

flutter pub add flutter_animate

Simple as that, the package gets installed; confirm by checking the flutter_animate entry in the pubspec.yaml.

Let's start implementing some animations, shall we? Hit Next to continue.

Let's go to one of the previous features developed - the welcome feature. Navigate to the features/welcome/presentation/pages/welcome_page.dart file. Let's accomplish the following animation:

App

Let's proceed.

First, import the flutter_animate package in the welcome_page.dart file.

import 'package:flutter_animate/flutter_animate.dart';

In the Column widget that contains all widgets that compose this page, at the end of the array assigned to its children property, add the following method:

//... rest of the code omitted for brevity

Column(
    children: [
        // all widgets are here:
        // Row()
        // GreetingsLabel()
        // Text.rich()
        // Row()
    ].animate(),
),

The animate() extension method adds the capability to the implementing widget of being animated. This extension method has parameters and callbacks (which we'll explore in depth as we make progress). For now, we want to cause a staggered animation across all children in this column, therefore set the interval property to 100.ms as such:

//... rest of the code omitted for brevity

.animate(
    interval: 100.ms
),

This will cause a 100-millisecond interval in between child animations. Let's continue.

The animate() method allows you daisy-chain effect extension methods, such as fadeIn(), scale(), slideX(), etc. We'll use slideY() as we want each item to slide up from the bottom, beginning at one fractional unit and ending at its original position of zero, with half a second duration, and a easeInOut curve, as shown:

//... rest of the code omitted

.animate(
    interval: 100.ms
)
.slideY(
    begin: 1, end: 0,
    duration: 0.5.seconds,
    curve: Curves.easeInOut
),

Lastly, chain another effect - fadeIn() so as it slides, it fades the child in, as such:

.animate(
    interval: 100.ms
)
.slideY(
    begin: 1, end: 0,
    duration: 0.5.seconds,
    curve: Curves.easeInOut
).fadeIn(),

The whole code on the Column children would look like this:

//... rest of the code omitted for brevity

Column(
    children: [
        // all widgets are here:
        // Row()
        // GreetingsLabel()
        // Text.rich()
        // Row()
    ].animate(
        interval: 100.ms
    )
    .slideY(
        begin: 1, end: 0,
        duration: 0.5.seconds,
        curve: Curves.easeInOut
    ).fadeIn(),
),

Animate the Hand Icon

Let's truly welcome the users into our app by literally waving at them! Let's animate the hand icon in our welcome page and make a waving animation using some of the same constructs available in the flutter_animate package.

In the same welcome.page.dart, locate the Icon widget that shows the wave icon (called PersonalPortfolioIcons.wave) and implement the following:

Your code should look like this:

//... inside the welcome.page.dart,
//... animate the Icon widget

const Icon(
    PersonalPortfolioIcons.wave,
    size: 90,
    color: PersonalPortfolioColors.welcomeIcon
).animate(
    onPlay:(controller) {
        controller.repeat(reverse: true);
    }
)
.rotate(
    begin: -0.25,
    end: 0,
    duration: 0.5.seconds,
    curve: Curves.easeInOut
),

Run the app again and you should be seeing a waving welcoming hand!!

App

And that's it! With just this little bit of code see how we've changed the page's entrance and provided some dynamism to it.

Your challenge is to try to accomplish what we show in the animation below:

App

Requirements:

Additional Challenge:

Notice in the animated GIF above how the Twitter Icon animates using a scale animation in a pulsating effect. Try to accomplish this animation. Here are some tips:

Since all the other pages follow a similar simple structure (its children are laid out vertically inside a Column), implement the animate().slideY().fadeIn() as before with the required parameters, therefore follow the welcome feature page animation codelab from a few steps ago - or you can personalize it and make adjustments as you please. Make them your own!

So far we've been able to implement animations to the widgets inside the pages. Let's add some animations in between the page transitions, making them fade in as we navigate to them. For that, we'll leverage some of the widgets provided by GoRouter that facilitate this.

We'll be accomplishing this:

App

Current Transitions being implemented as of now

If you go to the routes/app_routes.dart file and checkout how we implemented the routes, you may notice that every page returned by the corresponding is wrapped inside a NoTransitionPage widget, as such:

//... inside each route's pageBuilder

pageBuilder: (context, state) {
    return const NoTransitionPage(  // <<---- from GoRouter
        child: GithubPage()
    );
}

The NoTransitionPage is a widget that allows you to implement a custom transition page with no transition. We use custom transition page widgets provided by GoRouter in conjunction with implementing the pageBuilder method available in every GoRoute object.

The default transition from the GoRouter package (if no transition page is added) is a slide transition (when instead of the pageBuilder we use the builder callback), as the one that displays the SplashPage and then the PortfolioPage shell page, as such:

//... DO NOT COPY, JUST AN EXAMPLE!
//... we use the default transitions 
//... available out of the box (i.e. for the SplashPage)
//... if  you don't add anything, you'll get a slide animation

builder: (context, state) {
    builder: (context, state) => const SplashPage(),
},

Since we want custom transitions, we will continue tapping into the pageBuilder method and we will replace the existing NoTransitionPage and implement our own CustomTransitionPage.

There are several approaches to implement it (even using MaterialPage); one of them is as follows (at a bare minimum):

This is how you'd implement it per page route (don't copy the code just yet, this is just a first iteration sample):

// ... this is just a sample

GoRoute(
    parentNavigatorKey: Utils.tabNav,
    path: WelcomePage.route,
    pageBuilder: (context, state) {
        return CustomTransitionPage(
        key: state.pageKey,
            child: const WelcomePage(),
            transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeTransition(
                opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
                child: child,
            );
        });
    }
)

Notice the pageBuilder returns the new CustomTransitionPage instance, which wraps the child page WelcomePage and in turn the transitionBuilder triggers upon performing the navigation, executing a FadeTransition.

This is a lot of code which we'll have to duplicate for each page route. A better approach would be to encapsulate this inside an utility method.

Inside this same app_routes.dart, create a static method called pageTransition, whose job would be to encapsulate the instantiation of a CustomTransitionPage object, with the required parameters, such as key and page, as such:

//... inside the app_routes.dart,
//... at the bottom, add:

static CustomTransitionPage pageTransition({ required ValueKey key, required Widget page }) {
    return CustomTransitionPage(
        key: key,
        child: page,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return FadeTransition(
            opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
            child: child,
        );
    });
}

Now you can go ahead and use it on each of the routes as such (make this change on all the child routes to the ShellRoute):

//... do it for all the child routes
//... of the ShellRoute. rest of the routes omitted for brevity

GoRoute(
    parentNavigatorKey: Utils.tabNav,
    path: WelcomePage.route,
    pageBuilder: (context, state) {
        return AppRoutes.pageTransition(
            key: state.pageKey,
            page: const WelcomePage()
        );
    }
),

We still need to do one more improvement. While each of the pages is actually fading in, the background color still pops in, without a transition. Let's add a simple animation - an AnimatedContainer transition - to our PageColor shared widget, so that every time the gradient changes, we also rebuild that container widget and its corresponding gradient.

Go to the shared/widgets/pagecolor.dart and replace the existing Container widget returned by the following code:

//... rest of the code omitted for brevity

return AnimatedContainer(
    duration: 1.seconds,
    curve: Curves.easeInOut,
    decoration: BoxDecoration(
        gradient: pageGradient
    )
);

With this, as the navigation occurs and this widget gets notified of it, it will transition from one color to the other in a 1-second transition using an animation curve.

Now we should be all set. Take it for a spin and see!

App

In this implementation, we'll use a mixture of implicit and explicit animated widgets to bring much more interactivity and dynamic elements to our web app. We want the items in our navigation to enter using a slide transition, followed by scaling and fading once we toggle them.

This is what we'll be tackling:

App

Let's proceed.

We'll be animating the elements of the left navigation, so let's proceed to the LeftNavigation class in the features/navigation/presentation/widgets/left_navigation.dart file. Locate the Column widget to which we're feeding a list of LeftNavigationItemTile instances to its children property.

At the end of the List.generate method, add the following code:

//... inside the left_navigation.dart
//... rest of the code omitted for brevity

Column(
    children: List.generate(
        navItems.length, (index) {
        return LeftNavigationItemTile(
            item: navItems[index]
        );
    }).animate(  // <-- from the .animate down
        interval: 100.ms
    ).slideY(
        begin: 1, end: 0,
        duration: 0.5.seconds,
        curve: Curves.easeInOut,
    ).fadeIn(
        duration: 0.5.seconds,
        curve: Curves.easeInOut
    ),
),

In the code above, we are animating all items generated by the List.generate method in a staggered manner (using animate(interval: 100ms)), then we proceed to daisy-chain a slideY() so it slides up, followed by a fadeIn(), both with a duration of 0.5 seconds and an easeInOut curve. This takes care of the sliding and fading in part. This was the explicit animated part.

Now since they rebuild every time a new item is tapped so their state change is reflected accordingly, we'll tap into this and apply a couple of implicit animated widgets (the ones usually preceded by AnimatedXXX).

The convenient thing about these widgets is that you can animate a widget property by setting a target value, and when that value changes, the widget animates the property from the old value to the new one. But besides the target value for the animated property, you can choose a duration and a curve - not like in the explicit animations where we could control the interval, reverse, repeat, etc. and have more control.

Let's proceed to the LeftNavigationItemTile class in the features/navigation/presentation/widgets/left_navigation_item_tile.dart file.

Replace the whole content of its build method by the code below:

// in the left_navigation_item_tile.dart
// replace the whole build method:

@override
Widget build(BuildContext context, WidgetRef ref) {

    return AnimatedScale( // <-- we added an AnimatedScale widget
    duration: 0.25.seconds,
    curve: Curves.easeInOut,
    scale: item.isSelected ? 1 : 0.8,
    child: AnimatedOpacity( // <-- we also added an AnimatedOpacity widget
        opacity: item.isSelected ? 1 : 0.25,
        duration: 0.25.seconds,
        curve: Curves.easeInOut,
        child: Container(
        margin: const EdgeInsets.only(top: 20, bottom: 20),
        child: IconButton(
                iconSize: 30,
                icon: Icon(
                    item.icon,
                    color: Colors.white,
                ),
                onPressed: () {
                    ref.read(navigationItemsViewModelProvider.notifier).selectNavItem(item);
                }
            ),
        ),
    ),
    );
}

In this refactoring, we got rid of the navItemColor since we won't be managing the opacity of the color whether the item.isSelected property is true or false, but rather the two other properties we're interested in manipulating - the opacity of the whole button (via the AnimatedOpacity) and its scale (via the AnimatedScale). We set the color property to the Icon widget to solid white for simplicity.

We'll use the same flag to drive the animation as it toggles between the items, using a smooth and animated transition, nesting them one inside the other so as to execute then in parallel.

And with that, we implemented a mixture of implicit and explicit animations. Now we have a sliding animation for our navigation items in a staggered fashion, and as we toggle them on or off, we see a scaling and fading at the same time. Neat!

The following lab is not required as it is not part of the framework out of the box, but supplied by an external library / package. I just wanted to make you aware of its existence, in which you can add high-quality, professional-level grade, high-performant and complex animations using the Rive package, available in pub.dev.

If you want to continue to the next codelab, please do - here's the link.

If you want to check it out, stay - this is what I'll show you what we'll be building. Check out that for each page, in the background, I display a faded animation sequence corresponding to each page. Each of those animations was created in Rive:

App

Rive Flutter is a runtime library for Rive, a real-time interactive design and animation tool. This is their official site (did you know the Rive editor was developed in Flutter???)

Rive has two components - the editor and the runtimes. The editor is where you create and animate your designs. From here you can export your work as a video, GIF or PNG sequence, or as a .RIV if you are going to use a runtime.

Runtimes are open-source libraries that allow you to play and manipulate your animations in realtime across a variety of platforms - in this case we'll be using the Flutter runtime, packaged inside the rive package we mentioned above.

I'll show you step by step how I built these animations. For more in-depth tutorials on Rive, you can visit the Rive YouTube channel and their guide.

Preparing the assets for Rive

One of the great things about Rive is that it works with vector graphics, such as SVGs. You can use your favorite image editor that supports the creation, manipulation and export of SVGs. My editors of choice is Figma but any SVG editor would do. That means you'll end up with high-quality, sharp animations in our Flutter apps.

Make sure that every element that you want to animate is in a separate layer and a separate element, that way we can manipulate them freely and independently once we are in Rive. Once you're ready, export them as .SVG files.

App

Rive is free for up to 3 projects. For more projects you must upgrade.

Their editor is pretty intuitive.

Going to Rive's main site and clicking on Get Started launches the editor. You must create an account with a Gmail account (if you don't have one already), but the process is pretty quick. Go ahead and come back.

App

Once in the editor, click on the plus (+) icon at the top right corner to create a new artboard, which launches a dialog. Select Blank Artboard. Click Create.

App

Artboards are the space where you build your creations, both during creation and animation. You can have more than one artboard in a single file, and you can only have one active artboard at a time.

They can be any size and you can arrange them any way it makes sense to you.

More on artboards here.

App

While in Design Mode, drag your SVG assets to be manipulated inside Rive. Arrange them the way you want them to be before any animation is applied.

Make sure you have a separate artboard for your separate assets' animations. Set up the artboard according to your needs.

App

Flip to Animate mode to make your animations. You position your elements, set keyframes that represent their properties over a timeline for a period of time (position, rotation, scale, etc.), and Rive fills in the blanks for you - that's how animations work in Rive.

App

You can create multiple animation sequences, and play them as any of the three categories: One-Shot, Loop or Ping-Pong.

You can have nested artboards (as shown above). Notice how one artboard contains a single animation (the linkedin logo moving up and down) while in the artboard above you can copy the artboard below (and "nest" it) as many times, and even adjust their speeds.

One of the coolest things about Rive is the concept of State Machines.

State Machines are a visual way to connect animations together and define the logic that drives the transitions. They allow you to build interactive motion graphics that are ready to be implemented in your product, game, or website.

App

Your state machines execute your animations and connect them together creating a flow. Each animation is considered a state (idle, moving, stopped), and transitions allow you to move from state to state (from animation to animation) when a condition is satisfied - yeah, like a state machine.

You must know the name of the artboard (i.e. in our case, linkedin) and the name of the state machine (in our case, also linkedin), and sometimes for execution one-shot animations, the name of the animation itself (you guessed it - linkedin). That's why I like naming all of them the same for simple state machines with a single animation. You could also name them like linkedin_ab for artboard, linkedin_sm for state machine, linkedin_anim for animation, and it is a good practice when you have a bunch of them.

I create one artboard and a corresponding state machine for each of my pages: linkedin, twitter, github, web, welcome, so I should be all set once I get to Flutter to use them.

I didn't get into inputs but they are a great way to programmatically trigger states in your state machine, so make sure to check those out.

Once you're satistied with your animation, you can export it as a .RIV file, for consumption inside Flutter.

Here's a link to the animations I'm using (as a .RIV file). Download the .RIV by clicking on this link.

App

Hit Next to see how we use this .RIV file in Flutter in the next bonus lab.

As mentioned before, Rive Flutter is the runtime library for Rive.

This library allows you to fully control Rive files with a high-level API for simple interactions and animations, as well as a low-level API for creating custom render loops for multiple artboards, animations, and state machines in a single canvas.

Create a folder under assets called anims - this is where we'll place our .RIV files. Our .RIV file is called personal_portfolio.riv so we'll just drag it there (you should've downloaded the .RIV file in the previous bonus lab! Here it is again!).

Go ahead and install Rive for Flutter now, by running the command:

flutter pub add rive

Go to the pubspec.yaml and add an entry for your newly created assets subfolder, under flutter, as in:

//... under the flutter section
//... (make sure you get the intentation right!)
assets:
    - assets/anims/

We will create a specific component that based on the navigated route, it will load the appropriate animation (hence the reason why we called our artboards and state machines with the same name as our routes).

When the user navigates to the desired route, we will notify a shared component called BgAnimation to rebuild itself with the animation that matches the navigated route.

Let's go to the shared/providers/shared_providers.dart and create a StateProvider type String called bgPageRouteProvider:

//... inside the shared_providers.dart
//... at the bottom

final bgPageRouteProvider = StateProvider<String>((ref) {
  return WelcomePage.route;
});

Its sole purpose is to get its String state changed upon route changes, and notify any watching widgets (the BgAnimation widget in this case).

Go to the navigation feature, inside presentation/viewmodels/leftnavigation.viewmodel.dart, inside the selectNavItem, under the setting of the pageColorProviderNotifier, read the bgPageProvider, set its state to the item.route, and you should be good to go.

//... inside the leftnavigation.viewmodel.dart's
//... selectNavItem method:

// ADD THIS LINE...
ref.read(bgPageRouteProvider.notifier).state = item.route;

NOTE: We could've also implemented it as an interceptor in the app_routes.dart but here is fine as well.

This call will trigger a rebuild on any widget watching the bgPageRouteProvider provider. Let's go ahead now and create that component.

Before we create the component, let's create some mappings we need to trigger the correct animation that maps to the corresponding route.

In the helpers folder, create an enums.dart file, with the following content:

enum BackgroundAnimations {
  welcome,
  twitter,
  linkedin,
  github,
  web
}

In this file, we'll keep all our enums and such so this work will be the precursor to future work here in this file.

Also in the helpers folder, but now in the utils.dart file, let's create a quick mapping - a 1:1 relationship between our routes and these animation enum values, as such (make sure to add the page imports as needed):

//... in the utils.dart

static Map<String, BackgroundAnimations> pageRouteToAnimations = {
    WelcomePage.route: BackgroundAnimations.welcome,
    TwitterPage.route: BackgroundAnimations.twitter,
    LinkedInPage.route: BackgroundAnimations.linkedin,
    GithubPage.route: BackgroundAnimations.github,
    WebPage.route: BackgroundAnimations.web
};

Since this will be a shared component (just like the PageColor widget), and will sit right above it in the shell, go to the shared/widgets/ and create a new file called bganimation.dart.

Paste the following code - let's dissect it together (add the proper imports as usual):

// add the remaining imports as per your project
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rive/rive.dart';

class BgAnimation extends ConsumerStatefulWidget {

  const BgAnimation({
    super.key,
  });

  @override
  ConsumerState<BgAnimation> createState() => BgAnimationState();
}

class BgAnimationState extends ConsumerState<BgAnimation> {

  Map<String, RiveAnimation> animations = {};
  List<StateMachineController> controllers = [];

  @override
  void initState() {
    super.initState();
    preloadRiveAnimations();
  }

  void preloadRiveAnimations() {

    for (var animValue in BackgroundAnimations.values) {
      var animationName = animValue.name;
      animations[animationName] = RiveAnimation.asset(
        './assets/anims/personal_portfolio.riv',
        artboard: animationName,
        fit: BoxFit.contain,
        onInit: (Artboard artboard) {
          var smController = StateMachineController.fromArtboard(
            artboard,
            animationName
          )!;
          artboard.addController(smController);
          controllers.add(smController);
        }
      );
    }
  }

  @override
  void dispose() {
    for (var element in controllers) {
      element.dispose();
    }
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {

    var bgImageRoute = ref.watch(bgPageRouteProvider);
    var animationEnum = Utils.pageRouteToAnimations[bgImageRoute]!;
    var animation = animations[animationEnum.name];

    return Align(
      alignment: Alignment.bottomCenter,
      child: SizedBox(
        height: MediaQuery.of(context).size.height / 2,
        child: Opacity(
          opacity: 0.1,
          child: animation
        ),
      ),
    );
  }
}

First we create a class called BgAnimation which will encapsulate all Rive animations. We import Rive so we can use its constructs (StateMachineController and RiveAnimation). We create it as a ConsumerStatefulWidget since we want to leverage the lifecycle methods (such as initState) to initialize (and dispose()) to dispose of the animation controllers, as well as obtain a ref object to read / watch providers from within.

In the initState() method, we invoke a method called preloadRiveAnimations which does what it says - preload all animations, using the BackgroundAnimations enum values and looping through them to create RiveAnimation instances and populating a map called animations.

In the process, use RiveAnimation.asset to load the .RIV file from the assets, passing the name of the artboard from the corresponding enum in the iteration and a fit of BoxFit.contain (how it will fill its parent container), and since this call is asynchronous (we are loading a file - obviously!) we tap into the onInit method and wire up a callback, which gets triggered upon the .RIV file loading and the runtime ready. Then we instantiate a StateMachineController by calling the factory method fromArtboard, passing the instance of the artboard retrieved from loading, and the name of the state machine (luckily they both have the same name). We collect all StateMachineController instances in a collection to later dispose of them in the dispose() method when appropriate.

This widget, in its build method, we listen to the newly created provider bgPageRouteProvider which we'll get notified when the route changes and get the route information as a string - this string should match the names of both the artboard and state machine in the .RIV file - that's why we pick it up from the route being passed.

Here then we use our newly created mapping called pageRouteToAnimations within the Utils so we can grab the correct enum value, which by using its name property, we get the string representation, and in turn we pull the corresponding preloaded animation from the animations map.

Lastly, we return the corresponding animation wrapped inside a SizedBox sized half of the screen, then inside an Align widget - and voilá! Right away as soon as things load, the animation will execute. No need to trigger anything since we made it so it just runs straight through and runs the animation matching the artboard and state machine. Simple, right?

And everytime someone navigates to the corresponding route, we get notified (since we're watching the provider), and rebuild accordingly, setting our animation with the right artboard and state machine.

Integrate the BgAnimation widget in the app

Now let's integrate it in the app. Go to the features/shell/presentation/porfoliomain.page.dart file, and add the BgAnimation widget right below the PageColor widget, so the animation shows on top of the background color, as such:

//... inside the portfoliomain.page.dart

body: Stack(
    children: [
        const PageColor(),
        const BgAnimation(), // <<--- add it here
        //... rest of the widgets omitted
    ],
)

And just like that, we have Rive animations taking our web app to a whole new level, beyond what implicit animations can take us! Kudos to you!!!

App

In this codelab, we accomplished the following:

More codelabs coming soon, so stay tuned!

Please don't forget to follow me on social media: