Skip to main content

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 a MutableValue<int>. A useState(0) works directly; a MutableValue.computed lets you project an enum or other selection state into an int.
  • builder - receives the managed PageController; pass it to your PageView.
  • onTransition - how to move the page when index changes programmatically. Defaults to controller.jumpToPage; pass this to animate instead.
  • controllerProvider - factory for the controller, defaulting to PageController.new. Override to set viewportFraction or keepPage.

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 PageView with an enum selection rather than a raw int, by projecting through MutableValue.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

  • index must be the single source of truth. The wrapper writes back to it on swipe, so feed it from owned state (a useState, or a MutableValue.computed over 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. Without onTransition, a programmatic index change snaps instantly. Pass onTransition with animateToPage when you want motion - reaching for it is the common case for buttons and deep links.

  • PageView mounts pages lazily and disposes them as they scroll out of view. If a page holds expensive state you need preserved, either opt that page into AutomaticKeepAliveClientMixin or use an IndexedStack (which keeps all children mounted) instead of a PageView.

  • StatelessPageControllerWrapper is deprecated - it is a typedef alias for this class. Use PageControllerWrapper directly.

See also