Layer link

Let’s take a look at how to create and display widgets that appear on top of other widgets and follow them when moved. This is an extremely useful experience for various elements like popups, dropdown menus, onboarding tours, and overlays beyond the parent element's rendering boundaries. At the same time, you should not forget that if the parent element is in the list or moves, the screen scrolls or moves in some other way - the overlay will not remain in place at the original coordinates but will follow the parent element.

Saying by examples, after this article, you can create custom drop-down menus like this:

Pop-up badges, hints, icons and other widgets:

Onboarding tours/overlays:

Or even more, attach “health bar” to your nimble jumping character:

Let's imagine you need to attach an overlay to a particular widget.

The problem of drawing overlay entries at specific coordinates doesn't look too hard. But it does. The overall layout of the widget with the overlay is in can be scrolled, transformed, repositioned, or otherwise mutated.

In other words, you should get the overlay following the widget by linking and tieing together two separate layers (parent widget and his overlay follower).

And here, two widgets in Flutter come to our aid. They are helping to connect the parent widget with the overlay: CompositedTransformTarget and CompositedTransformFollower.

If you want to see the whole code or see it in workshop mode:

In this article, I will demonstrate their use by creating a widget wrapper to help self-test your code and automate some actions when the application runs in debug mode.

First of all, we should create a new starter application.

import 'dart:async';
import 'dart:developer' as dev;

import 'package:flutter/material.dart';

void main() => runZonedGuarded<void>(
      () => runApp(const App()),
      (error, stackTrace) => dev.log(
        'A error has occurred',
        stackTrace: stackTrace,
        error: error,
        name: 'main',
        level: 1000,
      ),
    );

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Promter',
        home: Scaffold(
          appBar: AppBar(
            title: const Text('Promter'),
          ),
          body: const SafeArea(
            child: Placeholder(),
          ),
        ),
      );
}

After that, let's add our input form.

Also, let's complicate the layout with the help of transformations.

import 'dart:math' as math; // +
import 'package:flutter/services.dart'; // +

/*

  ...

  home: Scaffold(
    appBar: AppBar(
      title: const Text('Promter'),
    ),
    body: const SafeArea(
      child: SignUpForm(), // <== replace placeholder with new form
    ),
  ),

  ...

*/

class SignUpForm extends StatefulWidget {
  const SignUpForm({super.key});

  @override
  State<SignUpForm> createState() => _SignUpFormState();
}

class _SignUpFormState extends State<SignUpForm> {
  final TextEditingController _firstNameController = TextEditingController();
  final TextEditingController _secondNameController = TextEditingController();
  final TextEditingController _ageController = TextEditingController();
  final TextEditingController _emailController = TextEditingController();

  @protected
  void clearAll() {
    _firstNameController.clear();
    _secondNameController.clear();
    _ageController.clear();
    _emailController.clear();
  }

  @override
  void dispose() {
    _firstNameController.dispose();
    _secondNameController.dispose();
    _ageController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Column(
        mainAxisSize: MainAxisSize.max,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          const Expanded(
            child: Center(
              child: FlutterLogo(size: 120),
            ),
          ),
          Transform.rotate(
            angle: math.pi / 32,
            child: SizedBox(
              height: 108,
              child: ListView(
                padding: const EdgeInsets.all(24),
                scrollDirection: Axis.horizontal,
                physics: const BouncingScrollPhysics(
                  parent: AlwaysScrollableScrollPhysics(),
                ),
                children: <Widget>[
                  SizedBox(
                    width: 180,
                    child: TextField(
                      decoration: const InputDecoration(
                        labelText: 'First name',
                      ),
                      maxLines: 1,
                      keyboardType: TextInputType.text,
                      controller: _firstNameController,
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 180,
                    child: TextField(
                      decoration: const InputDecoration(
                        labelText: 'Second name',
                      ),
                      maxLines: 1,
                      keyboardType: TextInputType.text,
                      controller: _secondNameController,
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 120,
                    child: TextField(
                      decoration: const InputDecoration(
                        labelText: 'Age',
                        counterText: '',
                      ),
                      maxLength: 3,
                      keyboardType: TextInputType.number,
                      inputFormatters: [
                        FilteringTextInputFormatter.digitsOnly,
                      ],
                      maxLines: 1,
                      controller: _ageController,
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 240,
                    child: TextField(
                      decoration: const InputDecoration(
                        labelText: 'e-Mail',
                      ),
                      maxLines: 1,
                      keyboardType: TextInputType.emailAddress,
                      controller: _emailController,
                    ),
                  ),
                ],
              ),
            ),
          ),
          const Spacer(),
        ],
      );
}

Now, when we have a few text input fields, it isn't fascinating to fill them every time. Creating a new widget to help us fill them with stub data is a good idea.

Our new helper widget-wrapper contains the following API and overlay button, which will display an overlay with hints.

Add a stateful widget and three mixins for the state.

  • _PrompterApiMixin - our state API, with show and hide methods
  • _PrompterBuilderMixin - mixin contains the build method
  • _PrompterOverlayMixin - mixin for logic, responsible for overlay and tracking with position synchronization between two layers.

Create widget, state, and implement the first two mixins.

import 'package:flutter/foundation.dart' show kDebugMode; // +
import 'package:meta/meta.dart'; // +

// ...

class Prompter extends StatefulWidget {
  const Prompter({
    required this.child,
    required this.actions,
    super.key,
  });

  final Widget child;
  final Map<String, VoidCallback> actions;

  @override
  State<Prompter> createState() => _PrompterState();
}

class _PrompterState = State<Prompter>
    with _PrompterApiMixin, _PrompterBuilderMixin, _PrompterOverlayMixin;

mixin _PrompterApiMixin on State<Prompter> {
  @mustCallSuper
  @visibleForTesting
  @visibleForOverriding
  void show() {} // for further implementation

  @mustCallSuper
  @visibleForTesting
  @visibleForOverriding
  void hide() {} // for further implementation
}

mixin _PrompterBuilderMixin on _PrompterApiMixin {
  @override
  Widget build(BuildContext context) {
    if (!kDebugMode) return widget.child;
    return Stack(
      alignment: Alignment.bottomRight,
      fit: StackFit.loose,
      children: <Widget>[
        widget.child,
        Positioned(
          right: 2,
          bottom: 2,
          child: IconButton(
            icon: const Icon(Icons.arrow_drop_down_outlined),
            splashRadius: 12,
            alignment: Alignment.center,
            iconSize: 16,
            padding: EdgeInsets.zero,
            constraints: BoxConstraints.tight(
              const Size.square(16),
            ),
            onPressed: widget.actions.isEmpty ? null : show,
          ),
        ),
      ],
    );
  }
}

mixin _PrompterOverlayMixin on _PrompterApiMixin {
  // TODO: not implemented
}

And, of course, update our form, and wrap every text field with a new widget.

class _SignUpFormState extends State<SignUpForm> {
  final TextEditingController _firstNameController = TextEditingController();
  final TextEditingController _secondNameController = TextEditingController();
  final TextEditingController _ageController = TextEditingController();
  final TextEditingController _emailController = TextEditingController();

  @protected
  void clearAll() {
    _firstNameController.clear();
    _secondNameController.clear();
    _ageController.clear();
    _emailController.clear();
  }

  @override
  void dispose() {
    _firstNameController.dispose();
    _secondNameController.dispose();
    _ageController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Column(
        mainAxisSize: MainAxisSize.max,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Expanded(
            child: Center(
              child: Prompter(
                actions: <String, VoidCallback>{
                  'License page': () => showLicensePage(context: context),
                  'Unfocus': () => FocusScope.of(context).unfocus(),
                  'Clear all': clearAll,
                },
                child: const FlutterLogo(size: 120),
              ),
            ),
          ),
          Transform.rotate(
            angle: math.pi / 32,
            child: SizedBox(
              height: 108,
              child: ListView(
                padding: const EdgeInsets.all(24),
                scrollDirection: Axis.horizontal,
                physics: const BouncingScrollPhysics(
                  parent: AlwaysScrollableScrollPhysics(),
                ),
                children: <Widget>[
                  SizedBox(
                    width: 180,
                    child: Prompter(
                      actions: <String, VoidCallback>{
                        'John': () => _firstNameController.text = 'John',
                      },
                      child: TextField(
                        decoration: const InputDecoration(
                          labelText: 'First name',
                        ),
                        maxLines: 1,
                        keyboardType: TextInputType.text,
                        controller: _firstNameController,
                      ),
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 180,
                    child: Prompter(
                      actions: <String, VoidCallback>{
                        'Smith': () => _secondNameController.text = 'Smith',
                        'White': () => _secondNameController.text = 'White',
                      },
                      child: TextField(
                        decoration: const InputDecoration(
                          labelText: 'Second name',
                        ),
                        maxLines: 1,
                        keyboardType: TextInputType.text,
                        controller: _secondNameController,
                      ),
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 120,
                    child: Prompter(
                      actions: <String, VoidCallback>{
                        '24': () => _ageController.text = '24',
                        '32': () => _ageController.text = '32',
                        '75': () => _ageController.text = '75',
                      },
                      child: TextField(
                        decoration: const InputDecoration(
                          labelText: 'Age',
                          counterText: '',
                        ),
                        maxLength: 3,
                        keyboardType: TextInputType.number,
                        inputFormatters: [
                          FilteringTextInputFormatter.digitsOnly,
                        ],
                        maxLines: 1,
                        controller: _ageController,
                      ),
                    ),
                  ),
                  const SizedBox(width: 48),
                  SizedBox(
                    width: 240,
                    child: Prompter(
                      actions: <String, VoidCallback>{
                        'a@a.a': () => _emailController.text = 'a@a.a',
                        'a@tld.dev': () => _emailController.text = 'a@tld.dev',
                      },
                      child: TextField(
                        decoration: const InputDecoration(
                          labelText: 'e-Mail',
                        ),
                        maxLines: 1,
                        keyboardType: TextInputType.emailAddress,
                        controller: _emailController,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          const Spacer(),
        ],
      );
}

Let's create the layout for our Promter overlay.

class _PromterLayout extends StatelessWidget {
  const _PromterLayout({
    required this.actions,
    required this.hide,
  });

  final Map<String, VoidCallback> actions;
  final VoidCallback hide;

  @override
  Widget build(BuildContext context) => Card(
        margin: EdgeInsets.zero,
        child: Row(
          children: <Widget>[
            for (final action in actions.entries)
              Padding(
                padding: const EdgeInsets.all(2),
                child: ActionChip(
                  label: Text(action.key),
                  onPressed: action.value,
                ),
              ),
            const VerticalDivider(width: 4),
            IconButton(
              icon: const Icon(Icons.close),
              splashRadius: 12,
              alignment: Alignment.center,
              iconSize: 16,
              padding: const EdgeInsets.all(4),
              constraints: BoxConstraints.tight(
                const Size.square(24),
              ),
              onPressed: hide,
            ),
          ],
        ),
      );
}

Looks good. But now we need to write down the implementation for our helper inside _PrompterOverlayMixin.

mixin _PrompterOverlayMixin on _PrompterApiMixin {
  /// Object connecting [CompositedTransformTarget]
  /// and [CompositedTransformFollower].
  final LayerLink _layerLink = LayerLink();

  /// Current overlay entry, if it exists.
  OverlayEntry? _overlayEntry;

  @override
  void show() {
    super.show();
    hide();
    // Show overlay and set new _overlayEntry
    Overlay.of(context)?.insert(
      _overlayEntry = OverlayEntry(
        builder: (context) => Positioned(
          height: 48,
          // Wrap [CompositedTransformFollower] to allow our overlay
          // entry track and follow parent [CompositedTransformTarget]
          child: CompositedTransformFollower(
            link: _layerLink,
            offset: const Offset(-2, 4),
            targetAnchor: Alignment.bottomRight,
            followerAnchor: Alignment.topRight,
            showWhenUnlinked: false,
            child: _PromterLayout(
              actions: widget.actions,
              hide: hide,
            ),
          ),
        ),
      ),
    );
  }

  @override
  void hide() {
    super.hide();
    if (_overlayEntry == null) return;
    // Remove current overlay entry if it exists.
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  void dispose() {
    hide();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>

      /// Wrap your widget inside [CompositedTransformTarget]
      /// for tracking capabilities
      CompositedTransformTarget(
        link: _layerLink,
        child: super.build(context),
      );
}

After all, we can show an OverlayEntry with CompositedTransformFollower that will track and follow its target CompositedTransformTarget using a LayerLink.

A workshop at the link: Layer link workshop

A working example at the link: Full example