Last active
May 9, 2023 22:23
-
-
Save caseycrogers/e8b87daac4442f440ecebd6f15e69338 to your computer and use it in GitHub Desktop.
Declarative Page View Demo
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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