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?
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.