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, notcontroller.pageinbuild— the latter throws before attachment and rebuilds every scroll tick. - Keep the
PageControllerin the nearest common ancestor so view and indicator share state; if pages must sync with aTabBar(tabs, semantics, shared controller), useTabBarViewinstead — for free-form carousels and onboarding,PageViewis simpler.
Takeaway: a disposed PageController plus onPageChanged-driven dots gives you a production-grade swipeable flow in a handful of lines.