PageControllerWrapper
Owns a PageController and keeps it in two-way sync with a MutableValue<int> index you supply. It creates and disposes the controller, writes the index when the user swipes, and drives the controller when the index changes from elsewhere - so a PageView can be controlled by plain state instead of a hand-managed controller.
class Carousel extends HookWidget {
const Carousel({super.key});
Widget build(BuildContext context) {
// index is the source of truth; the wrapper keeps the PageController in sync with it.
final index = useState(0);
return Column(
children: [
Expanded(
child: PageControllerWrapper(
index: index,
builder: (controller) => PageView(
controller: controller,
children: const [Text('One'), Text('Two'), Text('Three')],
),
),
),
// Driving index from outside animates (or jumps) the PageView to that page.
Text('Page ${index.value + 1}'),
],
);
}
}
Constructor
PageControllerWrapper({
Key? key,
required MutableValue<int> index,
required Widget Function(PageController controller) builder,
void Function(PageController controller, int index)? onTransition,
PageController Function({int initialPage}) controllerProvider = PageController.new,
});
index- the source of truth for the current page, as aMutableValue<int>. AuseState(0)works directly; aMutableValue.computedlets you project an enum or other selection state into an int.builder- receives the managedPageController; pass it to yourPageView.onTransition- how to move the page whenindexchanges programmatically. Defaults tocontroller.jumpToPage; pass this to animate instead.controllerProvider- factory for the controller, defaulting toPageController.new. Override to setviewportFractionorkeepPage.
The controller is created once with initialPage: index.value and disposed automatically. A listener writes index from the controller's rounded page on every scroll; an effect runs onTransition (or jumpToPage) whenever index changes and differs from the controller's page - including the not-yet-attached case, guarded by hasClients.
Use cases
-
A swipeable multi-page shell (onboarding, an image carousel, a
PageView-backed tab body) where the active page lives in your state and the controller should just follow it. -
Animating between pages on a programmatic change by passing
onTransition:class AnimatedCarousel extends HookWidget {const AnimatedCarousel({super.key});Widget build(BuildContext context) {final index = useState(0);return PageControllerWrapper(index: index,// The default transition is jumpToPage. Pass onTransition to animate instead.onTransition: (controller, index) => controller.animateToPage(index,duration: const Duration(milliseconds: 300),curve: Curves.easeInOut,),builder: (controller) => PageView(controller: controller,children: const [Text('One'), Text('Two'), Text('Three')],),);}} -
Backing a
PageViewwith an enum selection rather than a raw int, by projecting throughMutableValue.computed:enum HomeTab { feed, search, profile }class HomeShell extends HookWidget {const HomeShell({super.key});Widget build(BuildContext context) {final currentTab = useState(HomeTab.feed);// Project the enum selection into the int the wrapper needs, both ways.final pageIndex = MutableValue<int>.computed(() => HomeTab.values.indexOf(currentTab.value),(it) => currentTab.value = HomeTab.values[it],);return PageControllerWrapper(index: pageIndex,builder: (controller) => PageView(controller: controller,children: [for (final tab in HomeTab.values) Text(tab.name)],),);}}
Caveats
-
indexmust be the single source of truth. The wrapper writes back to it on swipe, so feed it from owned state (auseState, or aMutableValue.computedover your selection) - never from a value that is itself recomputed elsewhere each build, or the two will fight. -
The default transition is
jumpToPage, not an animation. WithoutonTransition, a programmatic index change snaps instantly. PassonTransitionwithanimateToPagewhen you want motion - reaching for it is the common case for buttons and deep links. -
PageViewmounts pages lazily and disposes them as they scroll out of view. If a page holds expensive state you need preserved, either opt that page intoAutomaticKeepAliveClientMixinor use anIndexedStack(which keeps all children mounted) instead of aPageView. -
StatelessPageControllerWrapperis deprecated - it is a typedef alias for this class. UsePageControllerWrapperdirectly.
See also
- TabControllerWrapper - the same pattern for
TabBar/TabBarView - TextEditingControllerWrapper - the same controller-binding pattern for text input
- useState - the usual source of the
indexvalue