Building Complex UI in Flutter: Magic 8-Ball

Learn how to build complex user interfaces in Flutter by creating a nearly 3D Magic 8-Ball using standard Flutter components. By Nic Ford.

Leave a rating/review
Download materials
Save for later
Share

Flutter provides a great set of basic Material and Cupertino widgets — but maybe you want something a little different. A novel UI can give a user a far richer experience.

This tutorial describes how to create a nearly 3D Magic 8-Ball in Flutter! Using only standard Flutter components, you’ll develop an app that presents an 8-ball that you can spin to reveal its predictions.

During this process you’ll learn about:

  • Color Gradients, and what they contribute to a 2.5D world.
  • Matrix4 transformations, and how they morph a widget.
  • The Offset class — a simple structure that makes calculations easy.
  • Pan gestures, and how to map where they happen into your UX.

So, you’ll take that flat, old device screen and look at it with a little more — ahem — depth.

Note: This tutorial assumes some experience with Flutter, Dart and animations in Flutter. Check out Getting Started with Flutter for an intro to Flutter and Dart and Implicit Flutter Animations for Flutter’s Animation framework.

Getting Started

Download the starter project using the Download Materials button at the top or bottom of this tutorial. Open it in your favorite editor, build and run.

Device screen with the words 'I predict a riot'

Right now you can only see a very simple UI like the one shown above. Don’t worry, through this tutorial you’ll add the required logic to build your beautiful Magic 8-Ball in Flutter!

The project file structure looks like this:

Starter project file structure

The following Dart files are pre-built for you:

  • main.dart: The entry point for the whole shebang.
  • rules_of_the_oracle.dart: Simple instructions text.
  • prediction.dart: A Prediction widget that draws the Magic 8-Ball’s blue prediction triangle using a CustomPainter — which is outside the scope of this tutorial, so was pre-built for you — plus an obfuscated array of predictions.

Start by opening components/rules_of_the_oracle.dart and changing the text:

'Drag the Magic 8-Ball around\n'
'while concentrating on\n'
'the question you most\n'
'want answered.\n\n'
'Let go, and the oracle will\n'
'give you an answer - of sorts!',
Note: A long string that goes outside the editor window is tough to read, so split it into smaller strings on multiple lines. The compiler knows a sequence of strings is actually one big string — and it’s far easier to read.

Save the file and let the app hot reload to see the effect of this change:

Magic 8-Ball app screen with updated text

Now you need an 8-ball to spin — but before you start, some theory…

Creating Novel User Experiences: Beyond Material and Cupertino

Flutter enables you to create amazing user experiences right from the get-go. Whether using Material widgets, Cupertino or a combination of the two, everything you want to do has been catered for.

But look around, and you’ll see many apps don’t follow those rules. They know a novel user experience — so long as it follows a basic grammar — can be a stimulating one. Sometimes this is done to differ from the competition; other times, maybe, to realize a quick and intuitive engagement that would otherwise be hard to achieve; and sometimes, it’s art that just exists to give a soupçon of delight.

Here’s a great example. By adding a 3D twist to a standard storefront, Minh Pham has made something really special.

Minh Pham's 3D-flip guitar store

Understanding 2.5D

You can use a number of techniques to render a 3D object onto a two-dimensional surface. Mathematical 3D models probably give the best results — but they’re computationally costly and slow. So what can you do?

Easy. You can cheat.

One of the most popular cheats is the 2.5D — also known as the three-quarter or pseudo-3D — perspective, halfway between 2D and 3D. Two-dimensional images are rendered in such a way that the final effect looks three-dimensional. By composing 3D objects from 2D assets, you’ve done much of the hard work up front — so it’s far cheaper and quicker. Of course, you’ll have to make compromises — and you’ll hit those as the app progresses.

Understanding Neumorphic Design

One cheat you’ll use to great effect is neumorphic design. UI elements are rendered as if a light source is shining on them: Edges and features are highlighted or throw shadows. Neumorphic design is a subject that deserves a tutorial all its own — and luckily you have one, albeit outside the Flutter domain, here: How to Create a Neumorphic Design With SwiftUI.

But you’re probably itching to start. It’s time to build an 8-ball!

Building the Magic 8-Ball in Flutter

Start with the ball itself, or, as you may like to think of it, the Sphere of Destiny.

Create a components/sphere_of_destiny.dart file and add this code:

import 'package:flutter/material.dart';

class SphereOfDestiny extends StatelessWidget {
  const SphereOfDestiny({
    Key? key,
    required this.diameter
  }) : super(key: key);

  final double diameter;

  @override
  Widget build(BuildContext context) {
    return Container(

    );
  }
}

This code just defines SphereOfDestiny as a StatelessWidget. Note that it requires the diameter of the sphere as a double. You’ll work on the build method next.

Note: The widget key is typed as Key?, with a trailing query mark. Throughout this tutorial you’ll use sound null safety. To understand null safety in more detail, check out our tutorial on Non Nullable Dart.

Style and color Container, making it fit the space available:

return Container(
  width: diameter,
  height: diameter,
  decoration: BoxDecoration(
    color: Colors.black,
    shape: BoxShape.circle
  ),
);

By adding BoxDecoration, you tell Container how you want it to look: circular and black, in this case.

Go to the end of line 61 in main.dart. Hit Return to create a new line, and type SphereOfDestiny(diameter: 200),. Save your file and hot reload the app.

The screen now has a flat, black circle added

Now you have a black circle — but you want a sphere, a bit of depth. Enter neumorphic-cheat number one. In SphereOfDestiny replace the BoxDecoration color: line with:

gradient: RadialGradient(
  colors: const [Colors.grey, Colors.black],
),

Instead of a single, flat background color for Container, you’ve given it a gradient, grey at the center point to black at the edges.

Note: A color gradient shades an area from one color to another, progressing through intermediate tones on the way. Radial gradients, such as the one you’ve just added, start at a central point and radiate color changes outward.

Hot reload the app and now your circle looks a little more spherical.

The sphere is now shaded, light in the center to dark at the edges

Understanding the Difference That Lighting Can Make

The human eye is easily fooled, nearly as easily as the human who owns it. It’s a survival technique rather than a failing, enabling reaction to danger before the brain has even noticed. It’s a trait the 2.5D app developer can exploit.

To fool the eye into believing a flat circle is a sphere… add mood lighting. Specifically, a color gradient in the right position suggests not merely a shaded circle, but a sphere on which a light is shining.

However, if it is a sphere, it’s one lit by a flashlight from directly in front. That rarely happens in reality, so it doesn’t look right. You need to move the light source up, by moving the radial gradient’s center. Add a parameter to your RadialGradient:

center: Alignment(0, -0.75),

This places the center of the gradient — the point from which colors radiate — proportionally 75% up from the center to the top of Container.

Save your code and perform a hot reload.

Suddenly the circle is perceived as a sphere

Your eye perceives the light source to have moved above the object and what was a black circle now looks very much like a sphere.

Understanding Coordinate Systems

How does this Alignment work? What do its arguments mean?

You’re probably familiar with the school-taught coordinate system: a horizontal x-axis coupled with a vertical y-axis, meeting at the bottom-left corner — the origin.

However, that’s not the only coordinate system available. Flutter, for example, often places the origin in the top-left corner: As y increases, the position descends. That’s annoying if you’ve planned using the school system — but only a bit, because a simple function can translate these coordinates into what’s needed: (x, y) => (x, height − y).

Flutter’s Alignment widget uses a different, proportional system. The origin is right in the center, and x and y both lie in the range -1 (far left/top) to +1 (far right/bottom), with values between that are proportionally distanced from the center.

Proportional coordinate system with origin at the center

Time to start pulling it all together.

Using a Command-and-Control Widget

As you add features, things get more complex — so you’ll need a widget to manage interactions and subsequent UI changes: the “moving parts” of the app. It won’t have any distinct UI itself, instead calculating parameters for — and transforming the shape of — its children, including SphereOfDestiny. You’ll build that now.

Create a file, components/magic_8_ball.dart. Add a StatefulWidget called — you’ve guessed it — Magic8Ball.

class Magic8Ball extends StatefulWidget {

  const Magic8Ball({Key? key}) : super(key: key);

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

class _Magic8BallState extends State<Magic8Ball> {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Next, in main.dart, replace SphereOfDestiny(diameter: 200), with Magic8Ball(),. Save your code and perform another hot reload.

The sphere’s diameter should fit the screen, rather than using the arbitrary value of 200. Inside the build method of magic_8_ball.dart, add:

final size = Size.square(MediaQuery.of(context).size.shortestSide);

Because the sphere is fundamentally a circle, you need the largest square that can contain it. The media query returns the maximum space available — a rectangle — so Size.square using its shortest side gives you what you need.

Now, replace Container() with:

SphereOfDestiny(
  diameter: size.shortestSide
);

Do another hot reload and the sphere is back, bigger and better than ever.

A larger sphere is back

Lastly, the light source that makes the circle a sphere will also light up other features, so move it out from SphereOfDestiny into Magic8Ball, to make it ready to use. Add the following line to the top of _Magic8BallState:

static const lightSource = Offset(0, -0.75);

And pass it in as a parameter to the widget:

SphereOfDestiny(
  lightSource: lightSource,
  diameter: size.shortestSide
);

Now set up SphereOfDestiny to use it. Fix the red lines by adding lightSource as a required final parameter and change Alignment thus:

center: Alignment(lightSource.dx, lightSource.dy),

But why use an Offset when SphereOfDestiny requires Alignment?

Understanding the Glorious Offset Class

Offset is a simple service class that gives you a whole lot for free. Essentially, it represents an offset from a point — x and y coordinates, just like Alignment — but adds loads more too. In addition to coordinates, dx and dy, Offset also calculates the distance to the point and the angular direction in radians. Later on, when you apply a bit of trigonometry — don’t run away! — these will be invaluable.

Even better, those nice people at Flutter have written Offset to behave like a number. Offsets can be added to or subtracted from other Offsets or resized by multiplying by a scale factor. Really useful stuff, as you’ll see.

Understanding Matrix4 Transformations and the Three Axes

Since there’s a lightSource, the sphere should throw a shadow. Introducing the Matrix4 transformation.

Matrix4s transform the shape of a widget once it has been rendered by scaling it, translating its position or rotating the angle at which it’s displayed. Rotation goes a bit further than you might expect: You can rotate in three dimensions.

Note: Matrix4 transforms are so called because they use a 4×4 number matrix to map one space onto another. There’s a great discussion of these techniques here: 2D computer graphics.

The familiar x- and y-axes describe a 2D plane, left/right and up/down; but you can also use a third axis, z, which introduces in-out to the equation: 3D.

x, y and z axes

In fact, the z-axis is probably the one you’re most familiar with. Imagine a book lying on a table. Now turn it 45°. Since x and y describe the 2D table surface, you’ve just rotated it around the only axis left, z.

Note: It’s important to understand the difference between rotating around and rotating in. Think of a gymnast on the parallel bars: They’ll rotate around the bars, so around the x-axis; but their feet will come toward you, in the z-axis.

A gymnast rotating around the x-axis

If it were most people, their arms would also be desperately flailing sideways for balance. That’d be in the y-axis, around… well, everything.

A gymnast simulating the author on the bars

You can use Matrix4 to introduce a 2.5D concept of depth by rotating around the x- or y-axes — which is the same as rotating in the z-axis. For example, when you add a shadow.

Adding a Shadow of Doubt

Since a shadow mimics the thing that casts it, you can cheat a little here. Copy the entirety of SphereOfDestiny into a new file, components/shadow_of_doubt.dart, and rename accordingly: ShadowOfDoubt.

Remove the lightSource parameter — you won’t need that anymore — and replace RadialGradient with:

boxShadow: [
  BoxShadow(blurRadius: 25, color: Colors.grey.withOpacity(0.6))
],

To view this, you need to add it to Magic8Ball. Add Stack as a parent of SphereOfDestiny, then add ShadowOfDoubt with the same diameter as its sibling. For ease of viewing, add it after the sphere; later you’ll move it ahead of the sphere, so that the latter is uppermost.

class _Magic8BallState extends State<Magic8Ball> {
  @override
  Widget build(BuildContext context) {
    final size = Size.square(MediaQuery.of(context).size.shortestSide);
    return Stack(
      children: [
        SphereOfDestiny(
          lightSource: lightSource,
          diameter: size.shortestSide
        ),
        ShadowOfDoubt(
          diameter: size.shortestSide
        )
      ],
    );
  }
}

Import any necessary libraries.

import 'shadow_of_doubt.dart';

Perform a hot reload.

A gray shadow over the sphere

The sphere is partially obscured by a translucent disc — but that’s not how shadows work. How do you place it on the ground?

Using the Transform Widget

You need to rotate the shadow backward, gently taking it from a standing position to lying on the ground. Quick test: Which axis will it be rotated around, and which in?

[spoiler title=”Axes”]It will rotate around the x-axis and in the z-axis.[/spoiler]

First, add the math library to the top of components/shadow_of_doubt.dart.

import 'dart:math' as math;
Note: Some libraries expose a lot of top-level functions. For example, dart:math gives you pi, sin and many more. The as math modifier namespaces these functions — math.pi, math.sin, etc. — which ensures that nothing clashes and everything is easier to understand.

In ShadowOfDoubt, surround Container with a Transform widget, adding a transform parameter, thus:

Transform(
  transform: Matrix4.identity()..rotateX(math.pi / 2.1),
  child: Container(...)
)

The transform parameter takes a Matrix4 argument. The Matrix4.identity() constructor creates a transform which does nothing — identity means leave everything as-is. Then apply a rotation in x of math.pi / 2.1.

Perform another hot reload.

The sphere has a halo

Note: math.pi / 2 is a quarter of a circle in radians, so would rotate a quarter-circle back. That would be too much. A quarter-circle in the z-axis is exactly edge-on, which would be invisible since widgets are rendered infinitesimally thin. For this reason, you use 2.1 instead. Play with this value and see what happens.

At the moment, this is more of a halo than a shadow. Move it into the right position by adding origin to Transform, to tell it where to anchor its child:

origin: Offset(0, diameter),

No change in x, but move down the full size of the sphere in y.

Note: origin uses the top-left coordinate system discussed previously, rather than the proportional one you generally use.

Hot reload your app one more time.

The shadow in front of the sphere

Almost there now. The shadow is displayed at the bottom of the sphere, but still on the front.

Lastly, in _Magic8BallState, swap SphereOfDestiny and ShadowOfDoubt in the stack so the shadow is behind the sphere:

return Stack(
  children: [
    SphereOfDestiny(
      lightSource: lightSource,
      diameter: size.shortestSide
    ),
    ShadowOfDoubt(
      diameter: size.shortestSide
    )
  ],
);

Perform a hot reload and maybe make a cup of tea. :]

The shadow is safely seated beneath and behind the sphere

Adding the Prediction

No 8-ball is worth the money without predictions, so you need something to show people. Create a WindowOfOpportunity StatelessWidget in components/window_of_opportunity.dart. As with SphereOfDestiny, you’ll need a lightSource parameter, and you’ll also pass in a child: the Prediction itself.

const WindowOfOpportunity({
  Key? key,
  required this.lightSource,
  required this.child
}) : super(key: key);

final Offset lightSource;
final Widget child;

The prediction window on a Magic 8-Ball is a circular indentation, the lip of which casts a shadow. As before, use Container decorated with RadialGradient in build:

Container(
  decoration: BoxDecoration(
    shape: BoxShape.circle,
    gradient: RadialGradient(
      colors: const [Color(0x661F1F1F), Colors.black],
    )
  ),
  child: child
);

The indentation is shallow, its shadow quite small and the majority of the window is a uniform gray. You can simulate this by putting stops into RadialGradient: a list of doubles between 0 and 1, as many as there are colors. This enables you to specify proportional points at which one color starts transitioning to the next. By default, colors are distributed evenly, but adding stops at irregular intervals makes its color cover more or less of the gradient.

This gradient only has two colors. By pushing the first stop close to 1, you can make the first color — the uniform gray — cover most of the area. This value should vary according to how close the window is to lightSource: the closer to the source, the smaller the shadow, the closer the stop to 1.

Add a variable inside build:

final innerShadowWidth = lightSource.distance * 0.1;

And then add stops to your RadialGradient:

stops: [1 - innerShadowWidth, 1]

WindowOfOpportunity is really part of SphereOfDestiny — so, to show it, you need to add it as a child. Edit SphereOfDestiny to add child as a required parameter, then add it to Container:

return Container(
  ...
  child: child
);

Next, in _Magic8BallState, add a field to store the prediction text:

String prediction = 'The MAGIC\n8-Ball';

And lastly, add WindowOfOpportunity as the child of SphereOfDestiny, surrounded by Transform:

SphereOfDestiny(
  lightSource: lightSource,
  diameter: size.shortestSide,
  child: Transform(
    origin: size.center(Offset.zero),
    transform: Matrix4.identity()
      ..scale(0.5)
    ,
    child: WindowOfOpportunity(
      lightSource: lightSource,
      child: Prediction(text: prediction)
    ),
  )
)
Note: If you have any red-line issues, import any missing packages now to fix them.

The origin of Transform has been moved to the sphere’s center using the size.center convenience method. Similarly, you scale Matrix4 to half its parent’s diameter.

Why use Transform here? Widgets can be positioned in multiple ways, but Transform will prove useful later on. Hey, it’s a Magic 8-Ball — you should already know what the future has in store! :]

Perform a hot reload…

A window containing a prediction appears in the Magic 8-Ball

…but the shadow isn’t quite right. Shouldn’t it be longer the closer it is to the light source?

Using the same technique with which you moved the light source up on SphereOfDestiny, add a second variable into WindowOfOpportunity‘s build, just after innerShadowWidth:

final portalShadowOffset =
    Offset.fromDirection(math.pi + lightSource.direction, innerShadowWidth);

Red lines?

[spoiler title=”math”]Import the math library![/spoiler]

The default constructor for Offset takes x and y, calculating direction and distance. The Offset.fromDirection constructor complements this, taking direction and distance, and calculating x and y. Just what you need.

Adding math.pi — 180° in radians — to the direction pulls the shadow away from lightSource rather than toward it, so the nearest edge has the longest shadow.

Lastly, center RadialGradient as before:

center: Alignment(portalShadowOffset.dx, portalShadowOffset.dy),

Hot reload your app.

The shadow is longer nearer to the light source

The RadialGradient‘s default alignment has been shifted to the portalShadowOffset position, which is directly away from the light source. This makes the shadow rendered by the gradient nearest to the light source larger than that further away — which is exactly what happens to a shadow thrown by an edge in real life.

Getting Things Moving

The final step is adding some movement — the user dragging the window around the sphere in 2.5D, with it then bouncing back to its rest position when let go.

In keeping with the real Magic 8-Ball, you also want the blue triangle to fade out while dragging, and fade back in with a new prediction, at a jaunty angle, on release.

Setting the Rest Position

Right now WindowOfOpportunity sits in the sphere’s center, but it might look better a little higher. Add a static field to _Magic8BallState:

static const restPosition = Offset(0, -0.15);

Create a local copy of restPosition in build, as a local version will be useful later:

final windowPosition = restPosition;

Finally, move the window to the correct position by adding a translation to Transform before ..scale(0.5):

transform: Matrix4.identity()
..translate(windowPosition.dx * size.width / 2, windowPosition.dy * size.height / 2)
..scale(0.5),
Note: windowPosition is in the proportional coordinate system, but translate needs that converted to widget space, hence the multiplication.

Save and hot reload, and the window pushes up a jot.

The window is pushed up a little

Responding to Gestures

You need the window to track user gestures. In _Magic8BallState, add another Offset and surround SphereOfDestiny with GestureDetector:

Offset tapPosition = Offset.zero;

...

GestureDetector(
  onPanUpdate: (details) => _update(details.localPosition, size),
  child: SphereOfDestiny(...)
)

onPanUpdate provides instantaneous contact details as the finger moves. localPosition is an Offset with the point described relative to the area covered by the receiving widget.

Add _update:

void _update(Offset position, Size size) {
  Offset tapPosition = Offset(
    (2 * position.dx / size.width) - 1,
    (2 * position.dy / size.height) - 1
  );
  setState(() => this.tapPosition = tapPosition);
}

This translates the tap position within SphereOfDestiny into the proportional coordinate system you’re using. In the image below, position represents a point on the red graph. The calculations change it to a point on the black graph that exactly matches it.

Superimposed coordinate systems

In build, change windowPosition‘s definition:

final windowPosition = tapPosition == Offset.zero ? restPosition : tapPosition;

Save, hot reload and tap around a bit.

The window moves around the 8-ball

One thing to fix: The shadow cast by WindowOfOpportunity should really point to lightSource. This is easy to achieve — simply tell it the relative position:

child: WindowOfOpportunity(
  lightSource: lightSource - windowPosition,
  child: ...

By subtracting windowPosition from lightSource, you’re describing the latter relative to the former, wherever it’s moved. All hail Offset!

Save, and tap around some more.

Shadows are longer nearer to the light source

Warping the Window

It’s great to move the window around, but as soon as it leaves restPosition, it breaks the 3D illusion. As it moves, it must bend around the sphere and shrink as it approaches the edges.

Make the scale factor of Transform, which surrounds WindowOfOpportunity, dynamic:

..scale(0.5 - 0.15 * windowPosition.distance)

windowPosition.distance is measured from the center of the sphere. Right at the center, it’s zero, so you scale by 0.5; at the edges, it’s 1, so you scale by 0.35 and similarly between the two.

WindowOfOpportunity must also turn away as it bends around the sphere. Add three more Matrix4 transforms before scale:

..rotateZ(windowPosition.direction)
..rotateY(windowPosition.distance * math.pi / 2)
..rotateZ(-windowPosition.direction)
Note: You’ll need to import the math library once more.

The first rotateZ turns the widget to face windowPosition. The window is next rotateYed away from you around the y-axis by an amount proportional to windowPosition.distance. This equals 0° at the center and 90° — math.pi / 2 radians — at the edges. The second rotateZ turns the widget back again so the words remain upright — try it without this to see what it’s like.

Note: This shows that Matrix4 transforms are cumulative; the order they happen affects the results.

Right now, it’s possible to drag the window beyond the edges of the sphere: entertaining, but not very realistic. You need to constrain the distance.

In _update, add a line just before setState:

if (tapPosition.distance > 0.85) {
  tapPosition = Offset.fromDirection(tapPosition.direction, 0.85);
}

In other words, limit tapPosition to a maximum distance.

Save, and tap around some more.

Three rotated spheres

Adding Animations

Your 8-ball is pretty good, but here are a few final nice-to-haves for it:

  • Right now, the window jumps immediately to tapPosition. It would be great if it could visibly travel there, and then back to restPosition on release.
  • Prediction should fade out while being dragged, fading back with a new prediction.

These are easily realized by adding animations. In _Magic8BallState, add:

class _Magic8BallState extends State<Magic8Ball> with SingleTickerProviderStateMixin { // 1

  ...

  late AnimationController controller; // 2

// 3
  @override
  void initState() {
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
      reverseDuration: const Duration(milliseconds: 1500)
    );
    controller.addListener(() => setState(() => null));
    super.initState();
  }

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

  ...
}

Here’s what the above code does:

  1. Extends the class by adding with SingleTickerProviderStateMixin to its declaration.
  2. Adds an AnimationController field.
  3. Creates initState and dispose to initiate and dispose of AnimationController.

Moving the Window

Another useful thing Offset gives you is lerp, short for linear interpolation constructor.

Offset.lerp takes three arguments: two Offsets and a double in the range 0–1. If the double is 0, the first Offset is returned; if 1, the second Offset; and anything else, an Offset somewhere on the path between the two. Using lerp, you’ll animate the window from restPosition to tapPosition and back.

First, add onPanStart to GestureDetector:

GestureDetector(
  onPanStart: (details) => _start(details.localPosition, size),
  ...
)

void _start(Offset offset, Size size) {
  controller.forward(from: 0);
  _update(offset, size);
}

In the build method, again change how windowPosition is created:

final windowPosition = Offset.lerp(restPosition, tapPosition, controller.value)!;

You need WindowOfOpportunity to return to restPosition too, of course. Add another method:

GestureDetector(
  onPanEnd: (_) => _end(),
  ...
)

void _end() {
  final rand = math.Random();
  prediction = predictions[rand.nextInt(predictions.length)];
  controller.reverse(from: 1);
}

When the drag ends, the animation reverses, taking the window back from tapPosition to restPosition.

Could be more bouncy, though…

Having Fun with Curves

Introducing Flutter’s Curves library, transforming a boring, linear animation into something that accelerates and decelerates in interesting ways.

Add Animation to _Magic8BallState and initialize it — making sure this is below controller‘s creation in the code:

late Animation animation;

@override
void initState() {
  ...

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

  super.initState();
}

Change windowPosition once more:

final windowPosition = Offset.lerp(restPosition, tapPosition, animation.value)!;

Save, and giggle with delight as the window bounces back into position. :]

The window bounces

Fading the Prediction

Finally, fade Prediction out while it’s being dragged and change its angle on release so that it comes back slightly askew, just like real-world 8-balls.

Add and update an angle of wobble field:

double wobble = 0.0;

...

void _end() {
  ...
  wobble = rand.nextDouble() * (wobble.isNegative ? 0.5 : -0.5);
  ...
}
Note: wobble is in the range -0.5 to 0.5 radians, roughly -30° to +30°. Checking negativity allows you to invert the sign on each new prediction, making the wobble clockwise then counter-clockwise in turn.

Surround Prediction with Transform and Opacity widgets that vary according to the controller:

child: Opacity(
  opacity: 1 - controller.value, // fading out when moving
  child: Transform.rotate( // convenience method for `rotateZ`
    angle: wobble,
    child: Prediction(text: prediction)
  )
)

Save. Drag. See the future predicted.

The finished Magic 8-Ball

Where to Go From Here?

Congratulations on completing the tutorial!

You can download the final project using the Download Materials button at the top or bottom of this tutorial. From here, you can run and install the finished version of the app and browse around the finished project to see all the code.

You’ve really only touched the surface of what 2.5D design can do. Play with Gradients and Offsets, or if you’re feeling really enthused, delve into Matrix4 transformations.

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