Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active April 16, 2020 18:05
Show Gist options
  • Save slightfoot/85bc0c2db6672057b538d3620db52fc9 to your computer and use it in GitHub Desktop.
Save slightfoot/85bc0c2db6672057b538d3620db52fc9 to your computer and use it in GitHub Desktop.
Custom nested scroll thingy
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