Skip to content

Instantly share code, notes, and snippets.

@caseycrogers
Last active May 9, 2023 22:23
Show Gist options
  • Save caseycrogers/e8b87daac4442f440ecebd6f15e69338 to your computer and use it in GitHub Desktop.
Save caseycrogers/e8b87daac4442f440ecebd6f15e69338 to your computer and use it in GitHub Desktop.
Declarative Page View Demo
class DeclarativePageDemo extends StatefulWidget {
const DeclarativePageDemo({super.key});
@override
State<DeclarativePageDemo> createState() => _DeclarativePageDemoState();
}
class _DeclarativePageDemoState extends State<DeclarativePageDemo> {
ValueNotifier<PageValue> pageNotifier = ValueNotifier(PageValue.home);
@override
Widget build(BuildContext context) {
return Scaffold(
body: DeclarativePageView.fromValueNotifier<PageValue>(
valueNotifier: pageNotifier,
values: PageValue.values,
builder: (context, value) {
switch (value) {
case PageValue.home:
return const Center(child: Icon(Icons.home));
case PageValue.bookmarks:
return const Center(child: Icon(Icons.bookmark));
case PageValue.profile:
return const Center(child: Icon(Icons.person));
}
},
),
bottomNavigationBar: ValueListenableBuilder<PageValue>(
valueListenable: pageNotifier,
builder: (context, currPage, _) {
return BottomNavigationBar(
currentIndex: currPage.index,
items: PageValue.values.map(
(p) {
switch (p) {
case PageValue.home:
return BottomNavigationBarItem(
icon: const Icon(Icons.home),
label: p.name,
);
case PageValue.bookmarks:
return BottomNavigationBarItem(
icon: const Icon(Icons.bookmark),
label: p.name,
);
case PageValue.profile:
return BottomNavigationBarItem(
icon: const Icon(Icons.person),
label: p.name,
);
}
},
).toList(),
onTap: (pageIndex) {
setState(() {
pageNotifier.value = PageValue.values[pageIndex];
});
},
);
}),
);
}
}
enum PageValue {
home,
bookmarks,
profile,
}
class DeclarativePageView<T> extends StatefulWidget {
/// Base level constructor.
///
/// Usually you should us [DeclarativePageView.fromNotifier] instead but this
/// constructor may prove useful in some edge cases.
const DeclarativePageView({
super.key,
this.controller,
required this.currentValue,
required this.values,
required this.onChanged,
required this.builder,
this.animationDuration = const Duration(milliseconds: 200),
this.pageToValue,
});
/// Construct a page view that sets its current page index according to a
/// value notifier and updates that value notifier when the page changes
/// either because of a user drag or because the underlying `PageView`'s
/// controller jumped or animated to a new page.
static Widget fromValueNotifier<T>({
Key? key,
PageController? controller,
required ValueNotifier<T> valueNotifier,
required List<T> values,
required Widget Function(BuildContext, T) builder,
Duration animationDuration = const Duration(milliseconds: 200),
T Function(double)? pageOffsetToValue,
}) {
return ValueListenableBuilder<T>(
valueListenable: valueNotifier,
builder: (context, currentValue, _) {
return DeclarativePageView(
currentValue: currentValue,
values: values,
onChanged: (value) {
valueNotifier.value = value;
},
builder: builder,
);
},
);
}
/// The current value.
final T currentValue;
/// The list of values to create pages for.
final List<T> values;
/// Used to change external state on user interaction with the page view.
final ValueChanged<T> onChanged;
/// Included for legacy support and for listening to page offsets.
final PageController? controller;
/// The builder to create a page for the give value.
final Widget Function(BuildContext, T) builder;
/// The amount of time to animate when programmatically switching between
/// pages.
final Duration animationDuration;
/// Converts a PageOffset to an implied value.
///
/// The default behavior rounds the page offset to an int and then selects the
/// value corresponding to that index.
final T Function(double)? pageToValue;
@override
State<DeclarativePageView> createState() => _DeclarativePageViewState<T>();
int _valueToIndex(T value) => values.indexOf(value);
T _roundPageOffsetToValue(double pageOffset) {
return values[pageOffset.round()];
}
}
class _DeclarativePageViewState<T> extends State<DeclarativePageView<T>> {
final ValueNotifier<PageValue> currPage = ValueNotifier(PageValue.home);
late final PageController controller = widget.controller ??
PageController(
initialPage: widget.values.indexOf(widget.currentValue),
);
// This is the special sauce that tells us if current on changes are user
// drags or a programmatic call coming from inside the house.
// I forget where I saw this but somewhere in the bowels of ScrollPosition or
// something I remember seeing a hack like this for this exact situation.
Future<void>? animationDriver;
late ValueListenable<bool> isScrolling =
controller.position.isScrollingNotifier;
@override
void initState() {
super.initState();
controller.addListener(_onPageChanged);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
isScrolling.addListener(_onScrollStatusChanged);
});
}
@override
void dispose() {
controller.removeListener(_onPageChanged);
isScrolling.removeListener(_onScrollStatusChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant DeclarativePageView<T> oldWidget) {
super.didUpdateWidget(oldWidget);
// TODO: add logic for if `widget.values` has changed.
// TODO: add logic for if controller has changed.
// TODO: add logic for if pageOffsetToValue has changed.
if (isScrolling.value) {
// Any changes will be handled when scrolling finished.
return;
}
if (oldWidget.currentValue != widget.currentValue) {
final Future<void> driver = controller.animateToPage(
widget._valueToIndex(widget.currentValue),
duration: widget.animationDuration,
curve: Curves.linear,
);
animationDriver = driver;
driver.whenComplete(() {
if (animationDriver == driver) {
// Only set the driver to null if it is this driver. This way we don't
// erase a driver set by an animation interrupting us.
animationDriver = null;
}
});
}
}
@override
Widget build(BuildContext context) {
return PageView(
controller: controller,
children: widget.values.map((value) {
return Builder(
builder: (context) => widget.builder(context, value),
);
}).toList(),
);
}
void _onPageChanged() {
if (animationDriver != null) {
// This is a self-driven animation, ignore it.
return;
}
if (_impliedValue != widget.currentValue) {
// A user initiated scroll has changed the value, notify the caller.
widget.onChanged(_impliedValue);
}
}
void _onScrollStatusChanged() {
if (isScrolling.value) {
// We just started a scroll. Do nothing.
return;
}
final double page = controller.page!;
final int targetPage = widget._valueToIndex(widget.currentValue);
if (page - targetPage < controller.position.physics.tolerance.distance) {
// We're already at the target page, do nothing.
return;
}
final Future<void> driver = controller.animateToPage(
targetPage,
duration: widget.animationDuration,
curve: Curves.linear,
);
animationDriver = driver;
driver.whenComplete(() {
if (animationDriver == driver) {
// Only set the driver to null if it is this driver. This way we don't
// erase a driver set by an animation interrupting us.
animationDriver = null;
}
});
}
T get _impliedValue =>
widget.pageToValue?.call(controller.page!) ??
widget._roundPageOffsetToValue(controller.page!);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment