Last active
April 16, 2020 18:05
-
-
Save slightfoot/85bc0c2db6672057b538d3620db52fc9 to your computer and use it in GitHub Desktop.
Custom nested scroll thingy
This file contains hidden or 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
import 'dart:async'; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
void main() => runApp(ExampleApp()); | |
class ExampleApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
home: ExamplePage(), | |
); | |
} | |
} | |
class ExamplePage extends StatefulWidget { | |
@override | |
_ExamplePageState createState() => _ExamplePageState(); | |
} | |
class _ExamplePageState extends State<ExamplePage> with SingleTickerProviderStateMixin { | |
AnimatedAppBarController _controller; | |
final _tabs = <String>[ | |
'Simon', | |
'George', | |
'Jan', | |
]; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = AnimatedAppBarController(this); | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
super.dispose(); | |
} | |
void _onPageChanged(int page) { | |
_controller.showAppBar(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final mediaQuery = MediaQuery.of(context); | |
return Scaffold( | |
body: AnimatedAppBarContainer( | |
controller: _controller, | |
appBar: AppBar( | |
title: Text('Title'), | |
), | |
child: PageView( | |
onPageChanged: _onPageChanged, | |
children: [ | |
for (int i = 0; i < _tabs.length; i++) | |
StickyHeaderPageContainer( | |
stickyHeader: ShareBar( | |
color: Colors.primaries[i * 4], | |
), | |
child: CustomScrollView( | |
key: PageStorageKey<String>(_tabs[i]), | |
slivers: <Widget>[ | |
SliverPadding( | |
padding: EdgeInsets.only(top: mediaQuery.padding.top + kToolbarHeight), | |
sliver: SliverList( | |
delegate: SliverChildBuilderDelegate( | |
(BuildContext context, int index) { | |
if (index.isOdd) { | |
return Divider(height: 1.0); | |
} else if (index == 10) { | |
return StickyHeaderPlaceholder( | |
size: Size.fromHeight(kToolbarHeight), | |
); | |
} | |
return ListTile( | |
title: Text('${_tabs[i]} ${index ~/ 2}'), | |
); | |
}, | |
childCount: 60 * 2, | |
), | |
), | |
), | |
], | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
} | |
class AnimatedAppBarController { | |
AnimationController _controller; | |
Animation<double> _toolbarAnimation; | |
ScrollDirection _scrollDirection; | |
Timer _toolbarTimer; | |
Animation<double> get toolbarAnimation => _toolbarAnimation; | |
AnimatedAppBarController(TickerProvider vsync) { | |
_controller = AnimationController(duration: const Duration(milliseconds: 300), vsync: vsync); | |
_toolbarAnimation = Tween<double>( | |
begin: 0.0, | |
end: -kToolbarHeight, | |
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); | |
} | |
void updateAutoHide(ScrollDirection scrollDirection) { | |
if (scrollDirection != _scrollDirection) { | |
if (scrollDirection == ScrollDirection.forward) { | |
_toolbarTimer?.cancel(); | |
_toolbarTimer = Timer(const Duration(milliseconds: 250), () { | |
_controller.reverse(); | |
_scrollDirection = scrollDirection; | |
}); | |
} else if (scrollDirection == ScrollDirection.reverse) { | |
_toolbarTimer?.cancel(); | |
_toolbarTimer = Timer(const Duration(milliseconds: 250), () { | |
_controller.forward(); | |
_scrollDirection = scrollDirection; | |
}); | |
} | |
} | |
} | |
void showAppBar() { | |
_toolbarTimer?.cancel(); | |
_scrollDirection = ScrollDirection.idle; | |
_controller.reverse(); | |
} | |
void hideAppBar() { | |
_controller.forward(); | |
} | |
void dispose() { | |
_controller.dispose(); | |
} | |
} | |
class AnimatedAppBarContainer extends StatefulWidget { | |
const AnimatedAppBarContainer({ | |
Key key, | |
@required this.controller, | |
@required this.appBar, | |
@required this.child, | |
}) : super(key: key); | |
final AnimatedAppBarController controller; | |
final Widget appBar; | |
final Widget child; | |
static AnimatedAppBarController of(BuildContext context) { | |
final _AnimatedAppBarContainerState state = | |
context.ancestorStateOfType(TypeMatcher<_AnimatedAppBarContainerState>()); | |
return state.controller; | |
} | |
@override | |
_AnimatedAppBarContainerState createState() => _AnimatedAppBarContainerState(); | |
} | |
class _AnimatedAppBarContainerState extends State<AnimatedAppBarContainer> with SingleTickerProviderStateMixin { | |
AnimatedAppBarController get controller => widget.controller; | |
bool _onPageScrolled(ScrollNotification notification) { | |
if (notification is UserScrollNotification) { | |
final ScrollDirection scrollDirection = notification.direction; | |
controller.updateAutoHide(scrollDirection); | |
} | |
return true; | |
} | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
final mediaQuery = MediaQuery.of(context); | |
return Stack( | |
children: <Widget>[ | |
NotificationListener<ScrollNotification>( | |
onNotification: _onPageScrolled, | |
child: widget.child, | |
), | |
AnimatedBuilder( | |
animation: Listenable.merge([controller.toolbarAnimation]), | |
builder: (BuildContext context, Widget child) { | |
return Positioned( | |
top: controller.toolbarAnimation.value, | |
left: 0.0, | |
right: 0.0, | |
child: child, | |
); | |
}, | |
child: widget.appBar, | |
), | |
Container( | |
color: theme.primaryColor, | |
height: mediaQuery.padding.top, | |
), | |
], | |
); | |
} | |
} | |
class StickyHeaderPageContainer extends StatefulWidget { | |
const StickyHeaderPageContainer({ | |
Key key, | |
@required this.stickyHeader, | |
@required this.child, | |
}) : super(key: key); | |
final Widget stickyHeader; | |
final Widget child; | |
@override | |
_StickyHeaderPageContainerState createState() => _StickyHeaderPageContainerState(); | |
} | |
class _StickyHeaderPageContainerState extends State<StickyHeaderPageContainer> with AutomaticKeepAliveClientMixin { | |
Animation get toolbarAnimation => AnimatedAppBarContainer.of(context).toolbarAnimation; | |
@override | |
bool get wantKeepAlive => true; | |
final _stickyHeaderRect = ValueNotifier<Rect>(Rect.zero); | |
void onRectChanged(Rect rect) { | |
_stickyHeaderRect.value = rect; | |
} | |
@override | |
Widget build(BuildContext context) { | |
super.build(context); // AutomaticKeepAliveClientMixin | |
final mediaQuery = MediaQuery.of(context); | |
return Stack( | |
children: <Widget>[ | |
widget.child, | |
AnimatedBuilder( | |
animation: Listenable.merge([toolbarAnimation, _stickyHeaderRect]), | |
builder: (BuildContext context, Widget child) { | |
final top = toolbarAnimation.value + mediaQuery.padding.top + kToolbarHeight; | |
Rect rect = _stickyHeaderRect.value; | |
if (top > rect.top) { | |
rect = rect.translate(0.0, top - rect.top); | |
} | |
return Positioned( | |
left: 0.0, | |
top: rect.top, | |
right: 0.0, | |
height: rect.height, | |
child: widget.stickyHeader, | |
); | |
}, | |
), | |
], | |
); | |
} | |
} | |
class StickyHeaderPlaceholder extends StatefulWidget { | |
const StickyHeaderPlaceholder({ | |
Key key, | |
@required this.size, | |
}) : super(key: key); | |
final Size size; | |
@override | |
_StickyHeaderPlaceholderState createState() => _StickyHeaderPlaceholderState(); | |
} | |
class _StickyHeaderPlaceholderState extends State<StickyHeaderPlaceholder> { | |
_StickyHeaderPageContainerState _container; | |
ScrollPosition _position; | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
if (_position != null) { | |
_position.removeListener(_onScrollChanged); | |
} | |
_position = Scrollable.of(context)?.position; | |
if (_position != null) { | |
_position.addListener(_onScrollChanged); | |
} | |
_container = context.ancestorStateOfType(TypeMatcher<_StickyHeaderPageContainerState>()); | |
_onScrollChanged(); | |
} | |
@override | |
void dispose() { | |
if (_position != null) { | |
_position.removeListener(_onScrollChanged); | |
} | |
super.dispose(); | |
} | |
void _onScrollChanged() { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
if (mounted) { | |
final RenderBox box = context.findRenderObject(); | |
final offset = box.localToGlobal(Offset.zero); | |
_container?.onRectChanged(offset & box.size); | |
} | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return SizedBox.fromSize(size: widget.size); | |
} | |
} | |
class ShareBar extends StatelessWidget { | |
const ShareBar({ | |
Key key, | |
@required this.color, | |
this.elevation = 0.0, | |
}) : super(key: key); | |
final Color color; | |
final double elevation; | |
@override | |
Widget build(BuildContext context) { | |
return Material( | |
color: color, | |
elevation: elevation, | |
child: SizedBox( | |
height: kToolbarHeight, | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment