Components / navigation
Page indicator
Shows the current page of an onboarding or a carousel as dots whose active one stretches into a pill, bound to a page view, and lets people change page by touch.
Import
In a Flutter app where fliin init has run:
Examples
Onboarding
Three welcome pages that slide, and their dots tied to the same PageController with FlPageIndicator.controller: swiping moves the dots, touching a dot turns the page.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorOnboardingExample extends StatefulWidget {
const PageIndicatorOnboardingExample({super.key});
@override
State<PageIndicatorOnboardingExample> createState() => _PageIndicatorOnboardingExampleState();
}
class _PageIndicatorOnboardingExampleState extends State<PageIndicatorOnboardingExample> {
final _pages = PageController();
static const _titles = ['Plan your week', 'Share with your team', 'Stay on track'];
@override
void dispose() {
_pages.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
spacing: FlScope.of(context).space.lg,
children: [
Expanded(
child: PageView(
controller: _pages,
children: [for (final title in _titles) Center(child: FlText(title))],
),
),
FlPageIndicator.controller(
count: _titles.length,
controller: _pages,
semanticLabel: 'Intro',
pageLabel: (page, count) => 'Page $page of $count',
),
],
);
}
}Bars
FlPageIndicatorVariant.bars: the thin bars at the top of an onboarding, the current page drawn longer.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorBarsExample extends StatefulWidget {
const PageIndicatorBarsExample({super.key});
@override
State<PageIndicatorBarsExample> createState() => _PageIndicatorBarsExampleState();
}
class _PageIndicatorBarsExampleState extends State<PageIndicatorBarsExample> {
var _page = 1;
@override
Widget build(BuildContext context) {
return Center(
child: FlPageIndicator(
count: 4,
index: _page,
variant: FlPageIndicatorVariant.bars,
semanticLabel: 'Welcome',
pageLabel: (page, count) => 'Step $page of $count',
onChanged: (page) => setState(() => _page = page),
),
);
}
}Banner
Over a colored banner or a photo, color paints the dots in the color written on it; the other pages take it at 40 %, or inactiveColor.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorBannerExample extends StatefulWidget {
const PageIndicatorBannerExample({super.key});
@override
State<PageIndicatorBannerExample> createState() => _PageIndicatorBannerExampleState();
}
class _PageIndicatorBannerExampleState extends State<PageIndicatorBannerExample> {
var _page = 0;
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
return DecoratedBox(
decoration: ShapeDecoration(
color: theme.colors.accent,
shape: FlContinuousBorder(radius: theme.shape.radiusCard),
),
child: Padding(
padding: EdgeInsetsDirectional.all(theme.space.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.space.md,
children: [
Text(
'Free delivery this week',
style: theme.typography
.resolve(FlTextRole.heading5, heading: true)
.copyWith(color: theme.colors.onAccent),
),
FlPageIndicator(
count: 3,
index: _page,
color: theme.colors.onAccent,
semanticLabel: 'Offers',
pageLabel: (page, count) => 'Offer $page of $count',
onChanged: (page) => setState(() => _page = page),
),
],
),
),
);
}
}First
Three pages, the first one current. onChanged receives the dot touched; pageLabel names each page for the screen reader.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorFirstExample extends StatefulWidget {
const PageIndicatorFirstExample({super.key});
@override
State<PageIndicatorFirstExample> createState() => _PageIndicatorFirstExampleState();
}
class _PageIndicatorFirstExampleState extends State<PageIndicatorFirstExample> {
var _page = 0;
@override
Widget build(BuildContext context) {
return Center(
child: FlPageIndicator(
count: 3,
index: _page,
onChanged: (page) => setState(() => _page = page),
semanticLabel: 'Intro',
pageLabel: (page, count) => 'Page $page of $count',
),
);
}
}Middle
The second of three pages is current: the active dot stretches.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorMiddleExample extends StatefulWidget {
const PageIndicatorMiddleExample({super.key});
@override
State<PageIndicatorMiddleExample> createState() => _PageIndicatorMiddleExampleState();
}
class _PageIndicatorMiddleExampleState extends State<PageIndicatorMiddleExample> {
var _page = 1;
@override
Widget build(BuildContext context) {
return Center(
child: FlPageIndicator(
count: 3,
index: _page,
onChanged: (page) => setState(() => _page = page),
semanticLabel: 'Intro',
pageLabel: (page, count) => 'Page $page of $count',
),
);
}
}Last
The last page is current, as on the final step of an onboarding.
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';
class PageIndicatorLastExample extends StatefulWidget {
const PageIndicatorLastExample({super.key});
@override
State<PageIndicatorLastExample> createState() => _PageIndicatorLastExampleState();
}
class _PageIndicatorLastExampleState extends State<PageIndicatorLastExample> {
var _page = 2;
@override
Widget build(BuildContext context) {
return Center(
child: FlPageIndicator(
count: 3,
index: _page,
onChanged: (page) => setState(() => _page = page),
semanticLabel: 'Intro',
pageLabel: (page, count) => 'Page $page of $count',
),
);
}
}Usage
The code the reference images render:
final example = ColoredBox(
color: FlScope.of(context).colors.surface,
child: Padding(
padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: FlScope.of(context).space.sm,
children: [
for (final (count, index) in const [(3, 0), (3, 1), (3, 2), (7, 4)])
SizedBox(
height: FlScope.of(context).hitTarget,
child: Center(
child: FlPageIndicator(
count: count,
index: index,
semanticLabel: 'Introduction',
pageLabel: (page, count) => 'Page $page of $count',
onChanged: (_) {},
),
),
),
],
),
),
);API
FlPageIndicatorVariant: enum {dots, bars}
FlPageIndicatorVariant.dots: static final FlPageIndicatorVariant
FlPageIndicatorVariant.bars: static final FlPageIndicatorVariant
FlPageIndicator: class extends StatefulWidget
FlPageIndicator.new: const new({super.key, required int count, required int index, ValueChanged<int>? onChanged, required String Function(int page, int count) pageLabel, String? semanticLabel, FlPageIndicatorVariant variant, Color? color, Color? inactiveColor})
FlPageIndicator.controller: get PageController?
FlPageIndicator.count: get int
FlPageIndicator.onChanged: get ValueChanged<int>?
FlPageIndicator.pageLabel: get String Function(int page, int count)
FlPageIndicator.semanticLabel: get String?
FlPageIndicator.index: get int?
FlPageIndicator.variant: get FlPageIndicatorVariant
FlPageIndicator.color: get Color?
FlPageIndicator.inactiveColor: get Color?
FlPageIndicator.createState: State<FlPageIndicator>()Reference renders
The 16 images published with version 0.2.2, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify page-indicator compares your copy to them.
















Use it to
- show which onboarding page is visible
- show dots under an image carousel
- show how many intro screens are left
- keep dots in sync with a swiping page view
- move to the next page by tapping the dots
- Not for
- sections of the same screen: use tabs (tabs)
- the progress of a task: use a progress indicator (progress)
- States
Source
The files fliin add page-indicator copies into your app, before your prefix and imports are applied.
page_indicator/fl_page_indicator.dart
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';
/// The look of an [FlPageIndicator].
enum FlPageIndicatorVariant {
/// Izydesk's `.dots`: dots of 8, 8 apart, the current page a `selected`
/// pill of 24, the others `onSurfaceMuted` at 40 %.
dots,
/// The header of Izydesk's onboarding: thin bars 6 high, 5 apart, the
/// current page 40 long, all in `onSurface`, the others at 30 %.
bars,
}
/// The dots of a sequence of pages (onboarding, carousel): the current page
/// stretches into a pill, the others stay dots ([variant]): solid capsules,
/// gaps from the `spacing` scale.
///
/// Bound to a `PageController` ([FlPageIndicator.controller]), it follows the
/// finger from one page to the next. On a given page ([FlPageIndicator]), the
/// pill slides there with the `motion.spring.indicator` spring, or jumps
/// there under reduced motion.
///
/// Touch follows the platform: on iOS, like `UIPageControl`, a tap before or
/// after the middle goes back or forward one page, and a drag scrubs through
/// the dots; elsewhere, a tap picks the nearest dot. The screen reader reads
/// a single adjustable node ("page 2 of 3"), and the keyboard arrows change
/// page.
class FlPageIndicator extends StatefulWidget {
/// The dots of [count] pages, on page [index]. [onChanged] receives the
/// page chosen by touch, keyboard or screen reader; when null, the dots
/// cannot be chosen.
const FlPageIndicator({
super.key,
required this.count,
required int this.index,
this.onChanged,
required this.pageLabel,
this.semanticLabel,
this.variant = FlPageIndicatorVariant.dots,
this.color,
this.inactiveColor,
}) : assert(count > 0, 'at least one page'),
controller = null;
/// The dots of [controller]'s pages, which they follow. Without
/// [onChanged], a choice animates the page view there in `medium2`, or
/// jumps there under reduced motion.
const FlPageIndicator.controller({
super.key,
required this.count,
required PageController this.controller,
this.onChanged,
required this.pageLabel,
this.semanticLabel,
this.variant = FlPageIndicatorVariant.dots,
this.color,
this.inactiveColor,
}) : assert(count > 0, 'at least one page'),
index = null;
/// The number of pages.
final int count;
/// Receives the chosen page, from 0 to [count] - 1.
final ValueChanged<int>? onChanged;
/// The value read by the screen reader, the page counted from 1:
/// `(page, count) => 'Page $page of $count'`.
final String Function(int page, int count) pageLabel;
/// What the dots stand for ("Introduction"); when null, the value alone.
final String? semanticLabel;
/// The current page, from 0 to [count] - 1; null with [controller].
final int? index;
/// The followed page view; null with [index].
final PageController? controller;
/// Round dots, or the thin bars of an onboarding's header.
final FlPageIndicatorVariant variant;
/// The current page's color: over a photo or a colored banner, the color
/// written on it (`onAccent` on `accent`). When null: `selected` for the
/// dots, `onSurface` for the bars.
final Color? color;
/// The other pages' color. When null: [color] at 40 % for the dots and 30 %
/// for the bars, or, without [color], `onSurfaceMuted` at 40 % and
/// `onSurface` at 30 %.
final Color? inactiveColor;
@override
State<FlPageIndicator> createState() => _FlPageIndicatorState();
}
class _FlPageIndicatorState extends State<FlPageIndicator> with SingleTickerProviderStateMixin {
/// The pill's position in pages, fractional during a movement.
late final AnimationController _position = AnimationController.unbounded(vsync: this, value: _target.toDouble());
bool _focused = false;
PageController? get _controller => widget.controller;
/// The page to reach: the given index, or the controller's rounded page.
int get _target => (widget.index ?? _controllerPage(widget.controller!).round()).clamp(0, widget.count - 1);
double _controllerPage(PageController controller) => controller.hasClients && controller.position.hasContentDimensions
? controller.page ?? controller.initialPage.toDouble()
: controller.initialPage.toDouble();
@override
void initState() {
super.initState();
_controller?.addListener(_follow);
}
@override
void didUpdateWidget(FlPageIndicator old) {
super.didUpdateWidget(old);
final previous = old.controller;
if (previous != _controller) {
previous?.removeListener(_follow);
_controller?.addListener(_follow);
_position.value = _target.toDouble();
return;
}
final index = widget.index;
if (index != null && index != old.index) {
if (FlMotion.of(context).reduced) {
_position.value = index.toDouble();
} else {
_position.driveTo(index.toDouble(), FlSpringMotion(FlScope.of(context).motion.indicator));
}
}
}
@override
void dispose() {
_controller?.removeListener(_follow);
_position.dispose();
super.dispose();
}
/// A page view is followed with the finger, without a spring; the value
/// read changes halfway.
void _follow() {
final before = _position.value.round();
_position.value = _controllerPage(_controller!);
if (_position.value.round() != before) setState(() {});
}
void _choose(int page) {
final clamped = page.clamp(0, widget.count - 1);
if (clamped == _target) return;
final onChanged = widget.onChanged;
if (onChanged != null) {
onChanged(clamped);
return;
}
final controller = _controller;
if (controller == null || !controller.hasClients) return;
if (FlMotion.of(context).reduced) {
controller.jumpToPage(clamped);
} else {
final motion = FlScope.of(context).motion;
controller.animateToPage(clamped, duration: motion.medium2, curve: motion.standard);
}
}
/// The page the drag has reached: the parent or the page view has not
/// rendered it yet, and each move only acts when it changes.
int? _scrub;
/// The iOS drag scrubs through the dots like `UIPageControl`: the page
/// view jumps there, without animation.
void _scrubTo(int page) {
final clamped = page.clamp(0, widget.count - 1);
if (clamped == (_scrub ?? _target)) return;
_scrub = clamped;
final onChanged = widget.onChanged;
if (onChanged != null) {
onChanged(clamped);
} else if (_controller case final controller? when controller.hasClients) {
controller.jumpToPage(clamped);
}
}
bool get _interactive => widget.onChanged != null || _controller != null;
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
final colors = theme.colors;
final xs = theme.space.xs;
final bars = widget.variant == FlPageIndicatorVariant.bars;
final metrics = bars
? _Metrics(dot: xs * 1.5, pill: xs * 10, gap: xs * 1.25, count: widget.count)
: _Metrics(dot: xs * 2, pill: xs * 6, gap: xs * 2, count: widget.count);
final rtl = Directionality.of(context) == TextDirection.rtl;
final ios = theme.platform == TargetPlatform.iOS;
final target = _target;
final interactive = _interactive;
final forward = rtl ? -1 : 1;
int pageAt(double x) {
final along = rtl ? metrics.width - x : x;
return metrics.nearest(along, target);
}
void tap(TapUpDetails details) {
final x = details.localPosition.dx;
if (!ios) return _choose(pageAt(x));
final after = (x > metrics.width / 2) != rtl;
_choose(target + (after ? 1 : -1));
}
final active = widget.color ?? (bars ? colors.onSurface : colors.selected);
final faded = widget.color ?? (bars ? colors.onSurface : colors.onSurfaceMuted);
final inactive = widget.inactiveColor ?? faded.withValues(alpha: faded.a * (bars ? _barOpacity : _inactiveOpacity));
Widget dots = CustomPaint(
size: Size(metrics.width, metrics.dot),
painter: _Dots(position: _position, metrics: metrics, active: active, inactive: inactive, rtl: rtl),
);
if (interactive) {
dots = GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTapUp: tap,
onHorizontalDragStart: ios ? (_) => _scrub = target : null,
onHorizontalDragUpdate: ios ? (details) => _scrubTo(pageAt(details.localPosition.dx)) : null,
onHorizontalDragEnd: ios ? (_) => _scrub = null : null,
onHorizontalDragCancel: ios ? () => _scrub = null : null,
child: dots,
);
}
final canIncrease = interactive && target < widget.count - 1;
final canDecrease = interactive && target > 0;
return FocusableActionDetector(
enabled: interactive,
mouseCursor: interactive ? SystemMouseCursors.click : MouseCursor.defer,
shortcuts: {
const SingleActivator(LogicalKeyboardKey.arrowRight): _StepIntent(forward),
const SingleActivator(LogicalKeyboardKey.arrowLeft): _StepIntent(-forward),
const SingleActivator(LogicalKeyboardKey.home): const _EdgeIntent(end: false),
const SingleActivator(LogicalKeyboardKey.end): const _EdgeIntent(end: true),
},
// An arrow that would change nothing is not consumed: it bubbles up
// to the page, which may use it.
actions: {
_StepIntent: _PageAction<_StepIntent>(
interactive: interactive,
count: widget.count,
target: target,
pageOf: (intent) => target + intent.pages,
choose: _choose,
),
_EdgeIntent: _PageAction<_EdgeIntent>(
interactive: interactive,
count: widget.count,
target: target,
pageOf: (intent) => intent.end ? widget.count - 1 : 0,
choose: _choose,
),
},
onShowFocusHighlight: (visible) => setState(() => _focused = visible),
child: FlFocusRing(
visible: _focused,
borderRadius: BorderRadius.circular(metrics.dot / 2),
// The node sits under the target, which gives it its rectangle.
child: FlHitTarget(
child: Semantics(
slider: true,
label: widget.semanticLabel,
value: widget.pageLabel(target + 1, widget.count),
increasedValue: canIncrease ? widget.pageLabel(target + 2, widget.count) : null,
decreasedValue: canDecrease ? widget.pageLabel(target, widget.count) : null,
onIncrease: canIncrease ? () => _choose(target + 1) : null,
onDecrease: canDecrease ? () => _choose(target - 1) : null,
child: dots,
),
),
),
);
}
}
/// Goes to the page [pageOf] derives from the intent; enabled only when
/// that page exists and differs from [target].
class _PageAction<T extends Intent> extends Action<T> {
_PageAction({
required this.interactive,
required this.count,
required this.target,
required this.pageOf,
required this.choose,
});
final bool interactive;
final int count;
final int target;
final int Function(T intent) pageOf;
final ValueChanged<int> choose;
@override
bool isEnabled(T intent) {
final page = pageOf(intent);
return interactive && page >= 0 && page < count && page != target;
}
@override
void invoke(T intent) => choose(pageOf(intent));
}
class _StepIntent extends Intent {
const _StepIntent(this.pages);
final int pages;
}
class _EdgeIntent extends Intent {
const _EdgeIntent({required this.end});
final bool end;
}
/// The opacity of the dots, and of the bars, of the other pages.
const double _inactiveOpacity = 0.4;
const double _barOpacity = 0.3;
/// The row's measurements: the pill takes the space of a dot plus
/// `pill - dot`, so the row keeps its width during the movement.
final class _Metrics {
const _Metrics({required this.dot, required this.pill, required this.gap, required this.count});
final double dot;
final double pill;
final double gap;
final int count;
double get width => count * dot + (pill - dot) + (count - 1) * gap;
/// The pill share of dot [i] when the pill is at [position]: 1 on its
/// page, 0 one page away or more.
double share(int i, double position) => (1 - (position - i).abs()).clamp(0.0, 1.0);
/// The dot whose center is nearest to [along], measured from the start,
/// with the pill on [page].
int nearest(double along, int page) {
var start = 0.0;
var best = 0;
var distance = double.infinity;
for (var i = 0; i < count; i++) {
final width = i == page ? pill : dot;
final d = (start + width / 2 - along).abs();
if (d < distance) (best, distance) = (i, d);
start += width + gap;
}
return best;
}
}
class _Dots extends CustomPainter {
_Dots({
required this.position,
required this.metrics,
required this.active,
required this.inactive,
required this.rtl,
}) : super(repaint: position);
final Animation<double> position;
final _Metrics metrics;
final Color active;
final Color inactive;
final bool rtl;
@override
void paint(Canvas canvas, Size size) {
final radius = Radius.circular(metrics.dot / 2);
var start = 0.0;
for (var i = 0; i < metrics.count; i++) {
final share = metrics.share(i, position.value);
final width = metrics.dot + (metrics.pill - metrics.dot) * share;
final left = rtl ? size.width - start - width : start;
canvas.drawRRect(
RRect.fromLTRBR(left, 0, left + width, metrics.dot, radius),
Paint()..color = Color.lerp(inactive, active, share)!,
);
start += width + metrics.gap;
}
}
@override
bool shouldRepaint(_Dots old) =>
old.position != position ||
old.metrics.dot != metrics.dot ||
old.metrics.pill != metrics.pill ||
old.metrics.gap != metrics.gap ||
old.metrics.count != metrics.count ||
old.active != active ||
old.inactive != inactive ||
old.rtl != rtl;
}