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
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

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