Flutter PageView and Page Controllers: Building Swipeable Screens
Have you ever used an app where you can swipe left or right to navigate between screens? Maybe you've seen it in onboarding flows, image galleries, or tutorial screens. In Flutter, this smooth, swipeable experience is powered by the PageView widget and its companion, the PageController.
In this article, we'll explore how to use PageView to create engaging, swipeable interfaces. We'll cover everything from basic implementations to advanced techniques like programmatic navigation, custom physics, and performance optimization. By the end, you'll be able to build smooth, professional swipeable screens in your Flutter apps.
What is PageView?
PageView is a scrollable widget that displays a list of child widgets one at a time, allowing users to swipe horizontally or vertically between them. It's perfect for creating carousels, onboarding flows, image galleries, and tab-like navigation experiences.
Think of PageView as a book where each page is a widget. Users can flip through the pages by swiping, and you can control which page is displayed programmatically.
How PageView Works:
Basic PageView Implementation
Let's start with a simple example. Here's the most basic PageView:
import 'package:flutter/material.dart';
class BasicPageViewExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PageView(
children: [
Container(color: Colors.blue, child: Center(child: Text('Page 1'))),
Container(color: Colors.green, child: Center(child: Text('Page 2'))),
Container(color: Colors.orange, child: Center(child: Text('Page 3'))),
],
);
}
}
This creates a horizontal PageView with three colored pages. Users can swipe left or right to navigate between them. The widget automatically handles the swipe gestures and animations.
By default, PageView scrolls horizontally. If you want vertical scrolling, set the scrollDirection property:
PageView(
scrollDirection: Axis.vertical,
children: [
// Your pages here
],
)
Understanding PageController
While a basic PageView works fine for simple cases, you'll often need more control. That's where PageController comes in. It acts as the bridge between your code and the PageView, giving you programmatic control over navigation.
PageController Architecture:
PageController allows you to:
- Navigate to a specific page programmatically
- Listen to page changes
- Control animation duration and curves
- Access the current page index
Here's how to use a PageController:
class PageControllerExample extends StatefulWidget {
@override
_PageControllerExampleState createState() => _PageControllerExampleState();
}
class _PageControllerExampleState extends State {
final PageController _pageController = PageController(initialPage: 0);
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
children: [
Container(color: Colors.blue, child: Center(child: Text('Page 1'))),
Container(color: Colors.green, child: Center(child: Text('Page 2'))),
Container(color: Colors.orange, child: Text('Page 3')),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_pageController.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
child: Icon(Icons.arrow_forward),
),
);
}
}
Notice a few important things here:
- We create the PageController in the State class
- We pass it to the PageView via the
controllerproperty - We dispose of the controller in the
disposemethod to prevent memory leaks - We can call
nextPage()to navigate programmatically
Common PageController Methods
PageController provides several useful methods for navigation:
// Navigate to the next page
_pageController.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
// Navigate to the previous page
_pageController.previousPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
// Jump to a specific page (without animation)
_pageController.jumpToPage(2);
// Animate to a specific page
_pageController.animateToPage(
2,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
The duration and curve parameters control how the animation feels. Common curves include Curves.easeInOut, Curves.ease, Curves.bounceOut, and many others.
Listening to Page Changes
Often, you'll want to know when the user swipes to a different page. You can listen to page changes using the PageController's listener or by using the onPageChanged callback:
class PageChangeListenerExample extends StatefulWidget {
@override
_PageChangeListenerExampleState createState() => _PageChangeListenerExampleState();
}
class _PageChangeListenerExampleState extends State {
final PageController _pageController = PageController();
int _currentPage = 0;
@override
void initState() {
super.initState();
_pageController.addListener(() {
setState(() {
_currentPage = _pageController.page?.round() ?? 0;
});
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Page $_currentPage'),
),
body: PageView(
controller: _pageController,
onPageChanged: (index) {
print('Page changed to: $index');
},
children: [
Container(color: Colors.blue, child: Center(child: Text('Page 1'))),
Container(color: Colors.green, child: Center(child: Text('Page 2'))),
Container(color: Colors.orange, child: Center(child: Text('Page 3'))),
],
),
);
}
}
You can use either approach. The onPageChanged callback is simpler for basic cases, while the listener gives you more control and access to fractional page values.
Building an Onboarding Flow
One of the most common uses of PageView is creating onboarding screens. Let's build a complete example:
Onboarding Flow Structure:
Now let's build a complete example:
class OnboardingScreen extends StatefulWidget {
@override
_OnboardingScreenState createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State {
final PageController _pageController = PageController();
int _currentPage = 0;
final List _pages = [
OnboardingPage(
title: 'Welcome!',
description: 'Discover amazing features',
icon: Icons.star,
),
OnboardingPage(
title: 'Easy to Use',
description: 'Intuitive interface for everyone',
icon: Icons.thumb_up,
),
OnboardingPage(
title: 'Get Started',
description: 'Start your journey today',
icon: Icons.rocket_launch,
),
];
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < _pages.length - 1) {
_pageController.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// Navigate to main app
Navigator.of(context).pushReplacementNamed('/home');
}
}
void _skip() {
Navigator.of(context).pushReplacementNamed('/home');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Skip button
Align(
alignment: Alignment.topRight,
child: TextButton(
onPressed: _skip,
child: Text('Skip'),
),
),
// PageView
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemCount: _pages.length,
itemBuilder: (context, index) {
return _buildPage(_pages[index]);
},
),
),
// Page indicator
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_pages.length,
(index) => _buildIndicator(index == _currentPage),
),
),
SizedBox(height: 20),
// Next button
ElevatedButton(
onPressed: _nextPage,
child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'),
),
SizedBox(height: 40),
],
),
),
);
}
Widget _buildPage(OnboardingPage page) {
return Padding(
padding: EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(page.icon, size: 100, color: Colors.blue),
SizedBox(height: 32),
Text(
page.title,
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Text(
page.description,
style: TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildIndicator(bool isActive) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 4),
height: 8,
width: isActive ? 24 : 8,
decoration: BoxDecoration(
color: isActive ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(4),
),
);
}
}
class OnboardingPage {
final String title;
final String description;
final IconData icon;
OnboardingPage({
required this.title,
required this.description,
required this.icon,
});
}
This example demonstrates several key concepts:
- Using
PageView.builderfor dynamic page creation - Tracking the current page to update UI elements
- Creating custom page indicators
- Handling navigation at the end of the flow
PageView.builder for Dynamic Content
When you have many pages or dynamic content, PageView.builder is more efficient than providing a static list of children. It builds pages on-demand:
PageView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ItemWidget(item: items[index]);
},
)
This is especially useful for large lists, as it only builds the visible pages and a few adjacent ones, improving performance.
Custom Scroll Physics
You can customize how the PageView feels when scrolling by providing custom physics:
PageView(
physics: ClampingScrollPhysics(), // Default - stops at edges
// or
physics: BouncingScrollPhysics(), // Bounces at edges (iOS style)
// or
physics: NeverScrollableScrollPhysics(), // Disables scrolling
// or
physics: PageScrollPhysics(), // Optimized for page-based scrolling
children: [
// Your pages
],
)
For most PageView use cases, PageScrollPhysics() provides the best experience, as it's optimized for page-based navigation.
Performance Considerations
When building PageViews with complex content, keep performance in mind:
- Use
PageView.builderfor large lists - Avoid heavy computations in the build method
- Consider using
RepaintBoundaryfor complex pages - Cache images and other expensive resources
PageView.builder(
controller: _pageController,
itemBuilder: (context, index) {
return RepaintBoundary(
child: ComplexPageWidget(data: items[index]),
);
},
)
Common Patterns and Tips
1. Disabling Page Snapping
By default, PageView snaps to pages. To allow free scrolling, use PageView with custom physics or set allowImplicitScrolling:
PageView(
allowImplicitScrolling: true,
children: [
// Your pages
],
)
2. Infinite Scrolling
To create an infinite loop, you can use a large itemCount and modulo arithmetic:
PageView.builder(
controller: PageController(initialPage: 1000),
itemBuilder: (context, index) {
final actualIndex = index % items.length;
return ItemWidget(item: items[actualIndex]);
},
)
3. Combining with TabBar
PageView works great with TabBar for synchronized navigation:
class TabBarPageViewExample extends StatefulWidget {
@override
_TabBarPageViewExampleState createState() => _TabBarPageViewExampleState();
}
class _TabBarPageViewExampleState extends State
with SingleTickerProviderStateMixin {
late TabController _tabController;
late PageController _pageController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_pageController = PageController();
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
_pageController.animateToPage(
_tabController.index,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
});
}
@override
void dispose() {
_tabController.dispose();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: 'Tab 1'),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3'),
],
),
),
body: PageView(
controller: _pageController,
onPageChanged: (index) {
_tabController.animateTo(index);
},
children: [
Container(color: Colors.blue, child: Center(child: Text('Page 1'))),
Container(color: Colors.green, child: Center(child: Text('Page 2'))),
Container(color: Colors.orange, child: Center(child: Text('Page 3'))),
],
),
);
}
}
Conclusion
PageView and PageController are powerful tools for creating swipeable interfaces in Flutter. Whether you're building onboarding flows, image galleries, or custom navigation patterns, these widgets provide the flexibility and performance you need.
Remember to:
- Always dispose of your PageController to prevent memory leaks
- Use PageView.builder for dynamic or large lists
- Consider performance implications with complex pages
- Listen to page changes to update your UI accordingly
- Experiment with different scroll physics to match your app's feel
With these concepts in hand, you're ready to create smooth, engaging swipeable experiences in your Flutter apps. Happy coding!