In Flutter, how does a PageView with a PageController work, and what do viewportFraction, onPageChanged and keepPage change?
answer
- a scroll view that snaps by page
- viewportFraction defaults to 1.0
- onPageChanged at the centre page
- page is a double while dragging
- TabBarView is a PageView underneath
basics
~20 sPageView is a scroll view that snaps to whole pages; a PageController, a ScrollController subclass, drives it with jumpToPage, animateToPage and nextPage. viewportFraction sets each page's share of the viewport, onPageChanged fires when the centred page changes, and keepPage restores the page.
solid answer
~50 s`PageView` lays out children one viewport-sized page each and, with `pageSnapping: true` by default, settles every fling on a page boundary. Its `PageController` extends `ScrollController` with `initialPage` (0), `keepPage` (true) and `viewportFraction` (1.0), plus `jumpToPage`, `animateToPage`, `nextPage` and `previousPage`. A `viewportFraction` of 0.85 makes each page 85 % of the width so neighbours peek in, centred unless `padEnds` is false. `onPageChanged` fires whenever the page in the centre of the viewport changes — mid-drag, not only when the fling settles — so it is the place to update an indicator. `controller.page` is a `double` that is fractional during a swipe, useful for parallax, and asserts when no or several `PageView`s are attached. `keepPage` stores the page in `PageStorage` so a rebuilt `PageView` returns to it. `TabBarView` is built on a `PageView` synced with a `TabController`.
code
dart · 49 linesimport 'package:flutter/material.dart';
class HeadlinerCarousel extends StatefulWidget {
const HeadlinerCarousel({super.key, required this.headliners});
final List<String> headliners;
@override
State<HeadlinerCarousel> createState() => _HeadlinerCarouselState();
}
class _HeadlinerCarouselState extends State<HeadlinerCarousel> {
final PageController _controller = PageController(viewportFraction: 0.85);
int _current = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(
height: 240,
child: PageView.builder(
controller: _controller,
itemCount: widget.headliners.length,
onPageChanged: (page) => setState(() => _current = page),
itemBuilder: (context, index) => AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final double page = _controller.hasClients && _controller.position.haveDimensions
? _controller.page!
: _current.toDouble();
final double scale = 1 - ((page - index).abs() * 0.1).clamp(0.0, 0.1);
return Transform.scale(scale: scale, child: child);
},
child: Card(child: Center(child: Text(widget.headliners[index]))),
),
),
),
Text('${_current + 1} / ${widget.headliners.length}'),
],
);
}
}go deeper
Recall that PageView swipes between full-size pages and that PageController moves it programmatically.
Explain viewportFraction, padEnds, onPageChanged timing, the fractional page value and keepPage.
Build peeking, scaling carousels from controller.page safely, and move settle-dependent work to scroll-end notifications.
Decide where paged surfaces fit the product and standardise one carousel behaviour across teams.
## What PageView is `PageView` is a scroll view whose items are **pages**, each as long as the viewport in the scrolling direction (horizontal by default). It uses page-aware physics so a drag or fling ends on a page boundary. Constructors mirror `ListView`: `PageView(children: ...)`, the lazy `PageView.builder(itemBuilder: ..., itemCount: ...)`, and `PageView.custom`. Defaults worth knowing: - `pageSnapping: true` — set `false` for free scrolling that still has page-sized items. - `padEnds: true` — with a fraction below 1.0, the first and last pages are centred instead of stuck to the edge. - `allowImplicitScrolling: false` — when `true`, accessibility focus can move into the next page, and neighbouring pages are cached. - `onPageChanged` — `ValueChanged<int>`, called whenever the centred page changes. ## PageController `PageController` extends `ScrollController`, so everything about attachment, `hasClients` and disposal applies. It adds page-based members: | Member | Default or type | Meaning | |---|---|---| | `initialPage` | `0` | page shown the first time the controller's `PageView` is built | | `keepPage` | `true` | save the current page with `PageStorage` and restore it on rebuild | | `viewportFraction` | `1.0` | fraction of the viewport each page occupies | | `page` | `double?` | current page, fractional mid-swipe; asserts with zero or several attached views | | `jumpToPage(int)` | `void` | move instantly | | `animateToPage(int, duration:, curve:)` | `Future<void>` | animate to a page | | `nextPage` / `previousPage` | `Future<void>` | animate by one page | ## viewportFraction for carousels For the festival app's headliner carousel, `PageController(viewportFraction: 0.85)` gives each card 85 % of the width and leaves the neighbours visible on both sides. Combine it with `controller.page` in an `AnimatedBuilder` (listening to the controller) to scale the centred card up and the side cards down: the distance `(controller.page! - index).abs()` goes smoothly from 0 to 1 as a card leaves the centre. ## onPageChanged timing The callback fires when the page **in the centre of the viewport** changes. During a drag that happens as soon as the next page crosses the midpoint, so an indicator updates while the finger is still down. If you need to act only after the page has settled — start a video, log a view — listen for a `ScrollEndNotification` instead. ## keepPage and rebuilding With `keepPage: true` a `PageView` that is disposed and rebuilt in the same route — for example inside a list that scrolled it out of view — returns to the saved page. When several `PageView`s share a route, give each a distinct `PageStorageKey` so they do not share one slot. ## TabBarView `TabBarView` is the Material wrapper that builds a `PageView` and keeps it in sync with a `TabController` (from `DefaultTabController` or your own). Swiping updates the tab indicator; tapping a tab animates the page. It takes `physics` and its own `viewportFraction` (default `1.0`). Disposing its internal page controller is its job; disposing your `TabController` is yours. ## Common mistakes 1. Creating the `PageController` in `build` — it resets the page on every rebuild. 2. Reading `controller.page` in `initState` — nothing is attached yet. 3. Using `onPageChanged` to start expensive work, then being surprised it fires mid-drag. 4. Forgetting to dispose the controller in the owning `State`.
- When exactly does onPageChanged fire during a swipe?Whenever the page in the centre of the viewport changes, which happens as soon as the next page crosses the midpoint during a drag. It can therefore fire before the fling settles, or fire and then change back if the user drags back. For work that should wait until the page is at rest, react to a `ScrollEndNotification`.
- How is TabBarView related to PageView?`TabBarView` builds a `PageView` with its own `PageController` and keeps it in sync with a `TabController`: swiping pages moves the tab indicator, and selecting a tab animates to that page. It exposes `physics` and `viewportFraction` (default `1.0`) and disposes its internal controller itself.
saying these in an interview costs you the question
- onPageChanged fires only after the swipe has fully settled.
- PageController.page is always a whole number.
- viewportFraction below 1.0 shows several pages without snapping.
- PageView builds every page up front like a Row.
- TabBarView is unrelated to PageView internally.