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