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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Building Complex UI in Flutter: Magic 8-Ball
35 mins
- Getting Started
- Creating Novel User Experiences: Beyond Material and Cupertino
- Understanding 2.5D
- Understanding Neumorphic Design
- Building the Magic 8-Ball in Flutter
- Understanding the Difference That Lighting Can Make
- Understanding Coordinate Systems
- Using a Command-and-Control Widget
- Understanding the Glorious Offset Class
- Understanding Matrix4 Transformations and the Three Axes
- Adding a Shadow of Doubt
- Using the Transform Widget
- Adding the Prediction
- Getting Things Moving
- Setting the Rest Position
- Responding to Gestures
- Warping the Window
- Adding Animations
- Moving the Window
- Having Fun with Curves
- Fading the Prediction
- Where to Go From Here?
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
Gradient
s, 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.
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.
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:
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 aCustomPainter
— 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!',
Save the file and let the app hot reload to see the effect of this change:
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.
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.
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.
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.
Hot reload the app and now your circle looks a little more spherical.
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.
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.
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.
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. Offset
s can be added to or subtracted from other Offset
s 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.
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.
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.
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.
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.
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;
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.
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.
origin
uses the top-left coordinate system discussed previously, rather than the proportional one you generally use.
Hot reload your app one more time.
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. :]
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 double
s 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)
),
)
)
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…
…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 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),
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.
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.
In build
, change windowPosition
‘s definition:
final windowPosition = tapPosition == Offset.zero ? restPosition : tapPosition;
Save, hot reload and tap around a bit.
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.
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)
math
library once more.
The first rotateZ
turns the widget to face windowPosition
. The window is next rotateY
ed 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.
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.
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 torestPosition
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:
- Extends the class by adding
with SingleTickerProviderStateMixin
to its declaration. - Adds an
AnimationController
field. - Creates
initState
anddispose
to initiate and dispose ofAnimationController
.
Moving the Window
Another useful thing Offset
gives you is lerp
, short for linear interpolation constructor.
Offset.lerp
takes three arguments: two Offset
s 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. :]
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);
...
}
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.
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 Gradient
s and Offset
s, 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.