Skip to content
Blog

PageView in Flutter: Building Swipeable Onboarding and Carousels

Build onboarding flows, carousels, and horizontal pagers with PageView, PageController, and a custom dot indicator.

Published on • November 2, 2026

AI Assistant

Onboarding screens, image carousels, and tutorials all share one interaction: swipe between full-screen pages. PageView is Flutter’s first-class widget for it (docs.flutter.dev).

PageController: The Control Surface

A PageController supplies initialPage, viewportFraction (peek the next page), and page for programmatic control — and it must be disposed:

final _c = PageController(initialPage: 0, viewportFraction: 0.9);
@override
void dispose() { _c.dispose(); super.dispose(); } // never leak the controller

Building Pages with PageView.builder

PageView.builder(
  controller: _c,
  itemCount: pages.length,
  physics: const ClampingScrollPhysics(), // default PageScrollPhysics; Bouncing on iOS
  allowImplicitScrolling: true,            // preloads neighbors + exposes semantics
  onPageChanged: (p) => setState(() => _page = p),
  itemBuilder: (context, i) => pages[i],
)

physics picks the feel: PageScrollPhysics (default, springy snap), ClampingScrollPhysics (hard edges), BouncingScrollPhysics (iOS rubber band). allowImplicitScrolling: true also gives accessibility services a real scroll extent.

End-to-End: Onboarding with Dot Indicator

class Onboarding extends StatefulWidget {
  const Onboarding({super.key});
  @override
  State<Onboarding> createState() => _OnboardingState();
}
class _OnboardingState extends State<Onboarding> {
  final _c = PageController();
  int _page = 0;
  @override
  void dispose() { _c.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Column(children: [
        Expanded(child: PageView(
          controller: _c,
          onPageChanged: (p) => setState(() => _page = p),
          children: const [WelcomePage(), FeaturesPage(), GetStartedPage()],
        )),
        Row(mainAxisAlignment: MainAxisAlignment.center, children: [
          for (var i = 0; i < 3; i++)
            AnimatedContainer(
                duration: const Duration(milliseconds: 250),
                margin: const EdgeInsets.symmetric(horizontal: 4),
                width: i == _page ? 24 : 8, height: 8,
                decoration: BoxDecoration(
                    color: i == _page ? Theme.of(context).colorScheme.primary : Colors.grey.shade400,
                    borderRadius: BorderRadius.circular(4))),
        ]),
        FilledButton(
            onPressed: () => _c.nextPage(duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic),
            child: const Text('Next')),
      ]);
}

Gotchas and When to Prefer TabBarView

  • Derive the dot indicator from onPageChanged, not controller.page in build — the latter throws before attachment and rebuilds every scroll tick.
  • Keep the PageController in the nearest common ancestor so view and indicator share state; if pages must sync with a TabBar (tabs, semantics, shared controller), use TabBarView instead — for free-form carousels and onboarding, PageView is simpler.

Takeaway: a disposed PageController plus onPageChanged-driven dots gives you a production-grade swipeable flow in a handful of lines.