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 3 of 5 of this article. Click here to view the first page.

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.