Lewis Cianci I'm a passionate mobile-first developer, and I've been making apps with Flutter since it first released. I also use ASP.NET 5 for web. Given the chance, I'll talk to you for far too long about why I love Flutter so much.

Creating a Flutter onboarding screen

8 min read 2265

Creating A Flutter Onboarding Screen

When it comes to mobile applications, first impressions count a lot. Normally, first impressions happen during the onboarding process where users set up the app on their phones. However, because onboarding is simply configuring the app to work for the first time, it can very easily become a boring process.

Onboarding must also cater to a large swath of users, ranging from users brought in from a marketing campaign, word of mouth, or seeing an app in the app store.

Regardless of how a user got to the app, the onboarding process must provide enough information for an informative but interesting process while retaining the new user.

A good onboarding process:

  • Provides an attractive to look and feel while being engaging to use
  • Presents the user with an opportunity to accept any required licenses or agreements
  • Gathers all appropriate data from users for using the app after the onboarding process

In this post, we’ll look at how we can create a suitable onboarding experience for an app called “Synergy Travel.” In many parts of the world, we can’t travel anywhere at the moment, so let’s channel all our travel desires into making a great onboarding experience instead! ✈

Final Onboarding Screen Shows Slide Show And Words Saying "Welcome To Synergy," Followed By An Onboarding Slideshow And Grid Of Interests

This is what our finished onboarding screen will look like. When opening the app, users see a travel-themed slideshow that scales and fades to draw them in, followed by the main onboarding process with a license agreement and a screen to select their interests.

Through our use of motion, we can create an engaging and interesting experience for our users.
So how do we accomplish this? Let’s find out.

Planning our Flutter app’s onboarding process

First things first, we must plan what our onboarding process looks like. In this case, let’s have the opening slideshow play and have the users’ view scroll down vertically into the main onboarding process.

If we imagine the light blue boxes are what the user can see on their phone, our onboarding process looks like this:

We made a custom demo for .
No really. Click here to check it out.

Onboarding Navigation Flow Shows Five Blue Boxes Showing How The App Will Move Through Each Onboarding Screen; The Boxes Contain "Opening Slideshow," "Welcome," "Accept License," "Choose Interests," and "Finalize The Onboarding Process"

Effectively planning the look that we are going for is important because we know what we are working towards. If we don’t know what we are aiming for, our code can become a mess.

Because we want our app to look good as soon as the user opens it, the first step in this onboarding process is to create an engaging opening slideshow.

Creating the opening slideshow in Flutter

Our opening slideshow consists of several pictures overlaid with a single word and our final slide shows all the words together.

While we could use a video to accomplish this without playing it on the device at runtime, we would probably encounter some compression artifacts and depend on another package, increasing the overall size of the app.

Instead, we’ll give Flutter what it needs to render the slideshow on the device to maintain a reduced installation package size and ensure the best visual presentation for our app.

To start creating this, let’s specify the words we want to show over the top of our opening slideshow:

final List<String> textOpeners = ['HELLO', 'WELCOME', 'TO', 'SYNERGY', 'HELLO,\r\nWELCOME\r\nTO\r\nSYNERGY'];

Right now, this is just a simple list of words. Our last value in this array uses line returns to space these words out when they display visually. However, we want our opening images to change every 2 seconds and display a button to begin the onboarding process on the last image.

Fortunately, Flutter ships with Timer.periodic that makes this kind of work a breeze:

void initState() {
  Timer.periodic(
    Duration(seconds: 2),
    (timer) {
      setState(() {
        if (index == 5) { // if we're at the end of the slideshow...
          timer.cancel(); //...stop running the timer
          setState(() {
            showStartCard = true; //...and show the button to begin the onboarding process
          });
        } else {
          index++; // otherwise, show the next slide
        }
      });
    },
  );
  super.initState();
}

Because we have our index incrementing by one every 2 seconds while calling setState, this triggers a rebuild of our widget to show the next image in our slideshow. This is referenced by AnimatedSwitcher that switches between the referenced images:

Widget build(BuildContext context) {
  return AnimatedSwitcher(
    duration: const Duration(milliseconds: 2000),
    child: Container(
      child: Stack(
        children: [
          Center(
            child: Text(
              textOpeners[index - 1],
              style: Theme.of(context).textTheme.headline3!.copyWith(
                    fontWeight: FontWeight.w900,
                    // color: Colors.white,
                  ),
            ),
          ),
          if (index == 5) // only on the last page
            AnimatedOpacity(
              duration: Duration(milliseconds: 400),
              opacity: showStartCard ? 1 : 0,
              child: Align(
                child: Padding(
                  padding: const EdgeInsets.all(80.0).copyWith(bottom: 120),
                  child: BottomTextInvite(
                    getStartedPressed: widget.getStartedPressed,
                  ),
                ),
                alignment: Alignment.bottomCenter,
              ),
            )
        ],
      ),
      key: ValueKey<int>(index),
      height: double.maxFinite, // occupy the entire screen
      width: double.maxFinite, // occupy the entire screen
      decoration: BoxDecoration(
        image: DecorationImage(
          fit: BoxFit.cover,
          image: AssetImage(
            'assets/opener/slide${index}.jpg',
          ),
        ),
      ),
    ),
  );
}

Using an AnimatedSwitcher, a Stack, and an AnimatedOpacity widget leads to a pretty good experience as each new slide fades in. But, while the opening slideshow looks okay, it doesn’t feel like a great experience yet; the colors blend together and the words aren’t very clear.

Initial Slideshow Layout Showing Tropical Pictures

Ideally, we want to find a way to improve the visual appeal and an easy way to do that is to introduce some form of motion that is pleasing to the eye.

It’s easy to overdo this, however, and to throw the user from screen to screen until they feel queasy is never optimal, so we need to add a level of nuance to ensure it adds to the experience but doesn’t take away from it.

To achieve this, we can combine a ScaleTransition and FadeTransition to produce an effect that looks good. Within our AnimatedSwitcher, we use the transitionBuilder to specify exactly how our changes to these widgets should take place:

transitionBuilder: (widget, animation) {
  final zoomAnimation = Tween(begin: 1.0, end: 1.3).animate(animation);
  final fadeAnimation = Tween(begin: 0.0, end: 1.0).animate(
    CurvedAnimation(
      parent: animation,
      curve: Interval(0.0, 0.2, curve: Curves.ease),
    ),
  );
  return FadeTransition(
    opacity: fadeAnimation,
    child: ScaleTransition(
      scale: zoomAnimation,
      child: widget,
    ),
  );
},

Note that we must consider some points when using zoomAnimation and fadeAnimation.

When using zoomAnimation, begin at 1.0 and end at 1.3. This is because the image begins filling 100% of the screen and ends at 130% of its original size to give the zoom effect. Also note that it runs for the entire duration of the page change operation.

When using fadeAnimation, begin at 0.0 and end at 1.0, making our transition go from completely transparent to completely opaque. We also use Interval to specify that this animation begins at the same time as the parent animation, but completes by the time the parent animation is only 20% completed. If we didn’t do this, our slideshow would be a perpetual scaling and fading mess.

Now that we’ve specified how we want to build our transitions, our opening slideshow looks more like this:

Adding The Fade And Zoom Features Between Photo Transitions

The last thing we need to do is add an AnimatedOpacity widget to fade the box in at the end after the opening slideshow completes. When the index of our image is 5 (the last image), we want to switch the opacity of our widget from completely transparent to completely opaque, like so:

  if (index == 5) // only on the last page
            AnimatedOpacity(
              duration: Duration(milliseconds: 400),
              opacity: showStartCard ? 1 : 0,
              child: Align(
                child: Padding(
                  padding: const EdgeInsets.all(80.0).copyWith(bottom: 120),
                  child: BottomTextInvite(
                    getStartedPressed: widget.getStartedPressed,
                  ),
                ),
                alignment: Alignment.bottomCenter,
              ),
            )

This gives us the fade-in result as we expect to see:

Adding The Fade-In Button To "GET STARTED"

Configuring the Flutter PageView widgets

To finish our opener, we require two configured PageView widgets. The first must operate on the vertical axis and move the viewport vertically after a user taps the button.

The user won’t be able to swipe this PageView widget to move around because it doesn’t logically make sense for the user to swipe back up into our opening slideshow.

The second widget must operate on the horizontal axis and move the viewport as the user swipes to move in a certain direction.

Because we have two PageView widgets nested inside of each other, either PageView can try to receive and process touch events, which is not what we want. Instead, we must set our outer ScrollView to use NeverScrollableScrollPhysics, and scroll it manually by using a ScrollController.

So, our root PageView and our child PageView look like this:

Widget build(BuildContext context) {
  return Scaffold(
    body: PageView( // Root PageView
      controller: outerScrollController, // The scroll controller that is used to programatically scroll the PageView
      physics: NeverScrollableScrollPhysics(), // Prevent the user from swiping
      scrollDirection: Axis.vertical,
      children: [
        ClipRect( // Prevent children from overflowing from the container
          child: EnticerOpenerPage(
            getStartedPressed: () => outerScrollController.animateToPage(
              1, // When the user presses the button, scroll down to the onboarding process.
              duration: Duration(seconds: 1),
              curve: Curves.fastOutSlowIn,
            ),
          ),
        ),
        Stack(
          children: [
            PageView( // The child PageView
              onPageChanged: (val) {
                setState(() {
                  // Update the scroll position indicator at the bottom
                  innerScrollPosition = val.toDouble();
                });
              },
              children: [...onboarding widgets...]
              ),
              Align(
                alignment: Alignment.bottomCenter,
                child: DotsIndicator( // Shows the user their progress
                  dotsCount: 4,
                  position: innerScrollPosition,
                  decorator: DotsDecorator(
                    size: const Size.square(9.0),
                    activeSize: const Size(18.0, 9.0),
                    activeShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5.0)),
    ),
  ),
)

Setting up onboarding steps in Flutter

Because our onboarding steps all commonly show some text and an image, we must declare a OnboardStep widget that accepts a list of children that we want to show in each step and show an image. If the image isn’t present, then the children render to the full size of the container:

class OnboardStep extends StatelessWidget {
  final Widget? image;
  final List<Widget> children;

  OnboardStep(
    this.children, {
    this.image,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.blue.shade200,
      child: Column(
        children: [
          if (image != null)
            Expanded(
              child: SafeArea(
                child: Padding(
                  padding: const EdgeInsets.all(20.0),
                  child: Card(
                    elevation: 10,
                    child: image!,
                    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
                  ),
                ),
              ),
              flex: 2, // occupy 2/3 of available space
            ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                mainAxisSize: MainAxisSize.max,
                mainAxisAlignment: MainAxisAlignment.center,
                children: children,
              ),
            ),
            flex: 1 // occupy 1/3 of available space,
          ),
        ],
      ),
    );
  }
}

An OnboardStep created with this widget is consistent in visual design across every step. To create our initial step, we just need to supply the text we want to show in this particular step and give an image to use. Invoking this widget is easy to do:

OnboardStep(
  [
    Text(
      'Get ready for the trip of a lifetime.',
      style: Theme.of(context).textTheme.headline5,
      textAlign: TextAlign.center,
    ),
    Text(
      'Synergy Holidays is a way for you to holiday, and really enjoy it.',
      textAlign: TextAlign.center,
    ),
  ],
  image: Padding(
    padding: const EdgeInsets.all(50.0),
    child: Image.asset('assets/explore.png'),
  ),
),

This code then produces these results:

Intro Slide To Onboarding Process To Request License Agreement And Interests

As long as we have some text to display with an optional image, we can easily display whatever we want at this particular stage of the onboarding process.

Creating the interest selection screen in Flutter

Usually, during an onboarding process, developers want to gather some sort of information from the customer, such as their email address or name.

In this case, we want to know what the user is interested in doing on their holidays so our app can make appropriate suggestions. Again, the subtle use of motion and feedback to the user can make this process feel enjoyable and high quality.

Our final interest selection screen looks like this:

Final Interest Selection Page Showing "Driving," "Exploring," "Discovery," "Rafting," "Relazation," and "Boating," With Thumbs Up Over "Exploring," "Discovery," And "Relaxation"

To begin building this page, we must construct a list of possible activities for the user to select from. We must also declare a Set to track what’s selected (we use a Set because items must be unique, unlike a List that allows duplicates):

final holidayTypes = [
  HolidayType('buggy.jpg', 'Driving'),
  HolidayType('cave_diving.jpg', 'Exploring'),
  HolidayType('exploration.jpg', 'Discovery'),
  HolidayType('rafting.jpg', 'Rafting'),
  HolidayType('relaxation.jpg', 'Relaxation'),
  HolidayType('water.jpg', 'Boating'),
];

final selectedHolidayTypes = <String>{};

As the user taps on the interests, the interests shrink in size and are overlaid with a thumbs-up icon. To achieve this, we must lay out our interests on a grid by using a GridView.

Again, we’ll use AnimatedContainer and AnimatedOpacity to handle the items shrinking and adding the thumbs-up icon display. When interests are tapped on, they are added or removed from the selectedHolidayTypes:

GridView.count(
  physics: NeverScrollableScrollPhysics(),
  shrinkWrap: true,
  crossAxisCount: 2,
  children: [
    ...holidayTypes.map(
      (e) => AnimatedContainer(
        duration: Duration(milliseconds: 100),
        padding: selectedHolidayTypes.contains(e.name) ? EdgeInsets.all(16) : EdgeInsets.zero, // Implicitly animate between full size, or shrunk size, depending if selected
        child: Card(
          clipBehavior: Clip.antiAlias, // Clip the overflow
          child: InkWell( // Display the inkwell splash when the user taps on an item
            onTap: () {
              setState(() {
                if (selectedHolidayTypes.contains(e.name)) {
                  // If the interest is already on the list, remove it
                  selectedHolidayTypes.remove(e.name);
                } else {
                  // Otherwise, add it
                  selectedHolidayTypes.add(e.name);
                }
              });
            },
            child: Ink.image(
              image: AssetImage(
                'assets/holidaytypes/${e.asset}',
              ),
              fit: BoxFit.cover, // Cover the entire container with the image
              child: Stack(
                alignment: Alignment.center,
                fit: StackFit.expand, // Expand children items to fit parent size
                children: [
                  // Align the label to the bottom center of the card.
                  Align(
                    child: Container(
                      padding: EdgeInsets.zero,
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Text(
                          e.name,
                          textAlign: TextAlign.center,
                        ),
                      ),
                      width: double.maxFinite,
                      color: Colors.white,
                    ),
                    alignment: Alignment.bottomCenter,
                  ),
                  // The 'thumbs-up' icon
                  AnimatedOpacity(
                    // If selected, show the thumbs-up icon
                    opacity: selectedHolidayTypes.contains(e.name) ? 1.0 : 0.0,
                    duration: Duration(milliseconds: 100),
                    child: Container(
                      height: double.maxFinite,
                      width: double.maxFinite,
                      // Overlay the image with a slight grey color
                      color: Colors.grey.withOpacity(0.3),
                      child: Icon(
                        Icons.thumb_up_alt_outlined,
                        color: Colors.white,
                        size: 50,
                      ),
                    ),
                  )
                ],
              ),
            ),
          ),
        ),
      ),
    )
  ],
)

The result of this code is an interactable button that looks like this:

Interactable Interest Button For "Driving," Shows User Clicking Button Followed By Animation And A Thumbs Up

Wrapping up

It’s important to wow your users from the get-go, and having an effective onboarding process can go a long way in accomplishing that. Fortunately, through the use of some basic motion and Flutters’ inbuilt implicit animations, it’s not difficult to achieve the exact result that you want.

As always, a link to the source for this example can be found here. I hope you make a really great onboarding process for your app! 🚀😊

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Lewis Cianci I'm a passionate mobile-first developer, and I've been making apps with Flutter since it first released. I also use ASP.NET 5 for web. Given the chance, I'll talk to you for far too long about why I love Flutter so much.

Leave a Reply