← Back to Articles

Flutter PageView and Page Controllers: Building Swipeable Screens

Flutter PageView and Page Controllers: Building Swipeable Screens

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:

Page 1 Page 2 Page 3 Swipe to navigate

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:

Your Code PageController PageView Controls Updates

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 controller property
  • We dispose of the controller in the dispose method 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:

Welcome Features Get Started Page Indicator Next Button Onboarding Flow Components

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.builder for 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.builder for large lists
  • Avoid heavy computations in the build method
  • Consider using RepaintBoundary for 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!