guided_tutor 0.2.0
guided_tutor: ^0.2.0 copied to clipboard
A dependency-free guided-tour overlay for Flutter: a scrim spotlights one widget at a time with a caption explaining it.
guided_tutor #

A hand-rolled, dependency-free guided tour overlay for Flutter: a scrim with one widget spotlighted through it and a caption explaining it, stepping through your screen — including scrolling to targets that start off-screen — one step at a time. The GIF above is a real tour, from a real app, running on this package.
No state-management dependency, no bundled localisation: you own the copy, the step list, and whatever you do with "has this been seen" — the package is just the overlay.
Why #
Most "onboarding tour" packages come with an opinionated look, their own
localisation story, and often a state-management dependency you didn't ask
for. guided_tutor is the opposite bet: it's a single, self-contained
widget, styled from your app's own Theme by default, that:
- skips steps whose target isn't currently in the tree, instead of drawing a spotlight around nothing — so the same step list works for an empty state and a screen full of data, and the step counter reports what is actually being shown, not the length of your list;
- scrolls a target into view before measuring it, waiting a frame for layout and a frame for the scroll to settle, which is the difference between a spotlight that lands on the widget and one that lands a few hundred pixels away from it;
- never traps the user — a "Skip" link is on screen on every step, the system back gesture leaves the tour rather than the screen under it, and only the caption's explanation scrolls, never its Back/Next controls off the bottom of the card.
Get started in under a minute #
This is the entire tutorial for the common case. Three steps: key it, describe it, show it.
1. Give the widgets you want to spotlight a GlobalKey #
final _headerKey = GlobalKey();
final _fabKey = GlobalKey();
// ... later, in build():
AppBar(key: _headerKey, title: const Text('Home')),
FloatingActionButton(key: _fabKey, onPressed: ..., child: const Icon(Icons.add)),
2. Describe the steps — a target key, a title, and a body, each #
final steps = [
const GuidedTourStep(
title: 'Welcome',
body: "This step has no target key, so it's about the screen as a "
'whole rather than one widget on it.',
),
GuidedTourStep(
targetKey: _headerKey,
title: 'The header',
body: 'Anything with a GlobalKey can be spotlighted.',
),
GuidedTourStep(
targetKey: _fabKey,
title: 'Add something',
body: 'Tap here to create your first item.',
shape: GuidedTourShape.circle, // round target, round hole
),
];
That's it for content — no labels, no locale, no shape you have to think about beyond the one circular target above.
3. Show it #
import 'package:guided_tutor/guided_tutor.dart';
ElevatedButton(
onPressed: () => GuidedTour.show(context: context, steps: steps),
child: const Text('Start tour'),
)
GuidedTour.show inserts the tour into the app's root Overlay and removes
itself the moment it's done — no Stack, no showTour boolean, no
setState to wire up. Next/Back/Skip/Done come from
GuidedTourLabels.standard (English) unless you pass your own. Run the
tour, tap through it, tap "Skip" — it behaves exactly like the full
walkthrough in example/, because it is the same GuidedTour underneath;
.show() is just the fast way to host it.
If you want to know when it closes (to persist "seen it"), pass onClose:
GuidedTour.show(
context: context,
steps: steps,
onClose: ({required completed}) => myPrefs.setTourSeen(true),
);
That's the whole tutorial. Everything below is for when you need more.
Going further #
Full control: hosting it yourself #
.show() covers most cases, but sometimes you want the tour's lifetime tied
to your own state — to gate it behind a persisted "have they seen this"
flag, to control exactly where in the tree it's mounted, or to swap it out
on a hot-reload during development. GuidedTour is a plain widget; host it
in a Stack instead:
// Stack it over your Scaffold, not inside one — the tour needs to be able
// to draw over everything it describes, a navigation bar included.
Stack(
children: [
scaffold,
if (showTour)
GuidedTour(
steps: steps,
onClose: ({required completed}) => setState(() => showTour = false),
),
],
)
showTour is a plain bool you own — set it from a "seen the tour before?"
check on startup, a Riverpod/Bloc notifier, or just setState. This is
exactly what .show() does for you under the hood, minus the OverlayEntry
bookkeeping.
Anchors, without your screen needing to know a tour exists #
TourAnchors<T> + TourAnchorScope<T> give you the GlobalKey plumbing for
free, generic over your own enum of "everything this tour can point at" —
useful once a tour spans more than a couple of widgets, since a flat enum
reads as the tour's whole route and a renamed/removed target becomes a
compile error instead of a silently-missing step:
enum HomeAnchor { header, list, fab }
class _HomeState extends State<Home> {
final _anchors = TourAnchors<HomeAnchor>(HomeAnchor.values); // once, as a field
@override
Widget build(BuildContext context) {
return TourAnchorScope<HomeAnchor>(
anchors: _anchors,
child: Scaffold(
appBar: AppBar(
title: TourAnchorScope.wrap(context, HomeAnchor.header, const Text('Home')),
),
// ...
),
);
}
}
Then build steps from _anchors[HomeAnchor.header] instead of juggling
GlobalKey fields by hand. Outside a TourAnchorScope, wrap/keyFor are
no-ops — a widget test or a design-preview index that builds your screen
without a tour above it just gets no anchors, quietly, rather than a crash
from a shared GlobalKey attached to more than one element at once.
Styling #
Every colour, size, and duration is read from GuidedTourStyle, whose
fields are all optional — leave them all unset and the tour reads its
colours and text styles from the ambient Theme:
GuidedTour.show(
context: context,
steps: steps,
style: const GuidedTourStyle(
ruleColor: Colors.amber,
cardMaxWidth: 360,
),
)
See the doc comments on GuidedTourStyle for the full list.
Circular targets #
A rectangular hole around a circular widget (a FAB, an avatar, a ring) lights
up its empty corners for no reason. Set shape: GuidedTourShape.circle on
that step and the hole is a circle sized to the target's longest side
instead.
Your own labels, for any language beyond English #
GuidedTourLabels(
next: 'Lanjut',
back: 'Kembali',
skip: 'Lewati',
done: 'Selesai',
// A builder, not a template string: the position it reports is what's
// actually being shown once absent targets are skipped, not a step's
// raw index.
counter: (position, total) => '$position dari $total',
)
Persistence #
Whether a given user has seen the tour, and whether it should open on its own, is deliberately left to you — there's no bundled storage to configure. The usual shape, wherever you already keep app preferences:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!myPrefs.hasSeenTour) {
GuidedTour.show(
context: context,
steps: steps,
onClose: ({required completed}) => myPrefs.setTourSeen(true),
);
}
});
}
Example #
example/ is a runnable app covering a target-less step, an off-screen
target the tour scrolls to, a step whose target is conditionally absent, and
a circular spotlight — started two ways from the same step list: the
AppBar's lightning-bolt icon calls GuidedTour.show, the floating action
button hosts GuidedTour in a Stack with its own state, so you can compare
both paths side by side.
What this package does not do #
- No persistence. "Has this user seen the tour" is your app's concern, not this widget's.
- No bundled copy or localisation beyond
GuidedTourLabels.standard's English defaults. Every string is handed in throughGuidedTourStep/GuidedTourLabels, already in whatever language you chose. - No animation/state-management dependency.
GuidedTouris a plainStatefulWidget; wire it up to Riverpod, Bloc,setState, or nothing at all.
License #
MIT. See LICENSE.