I have a requirement to build a view that has a tab bar within a collapsible app bar (so when the app bar collapses, just the tabs are visible) which controls a tab view, each of which will host a different sub view. Further, within those subviews on each tab, the content should be scrollable (which impacts the collapsed state of the app bar), and widgets within that scrollable view need to have headers that "stick" to the bottom of the app bar/tab bar.
A sliver-based implementation of sticky headers are available here: Flutter Sticky Headers
I took the first sample from Flutter's documentation on this page:
https://api.flutter.dev/flutter/widgets/NestedScrollView-class.html#widgets.NestedScrollView.1
and wrapped the SliverFixedExtentList in a SliverStickyHeader element and the headers are not sticking to the top of the list. I've tried this in a variety of places but can't seem to make a sticky header work using this library or even the original which used RenderObjects. Any thoughts as to why this might be?
Here's the full code as an example:
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
static const String _title = 'Flutter Code Sample';
#override
Widget build(BuildContext context) {
return const MaterialApp(
title: _title,
home: MyStatelessWidget(),
);
}
}
class MyStatelessWidget extends StatelessWidget {
const MyStatelessWidget({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final List<String> _tabs = <String>['Tab 1', 'Tab 2'];
return DefaultTabController(
length: _tabs.length, // This is the number of tabs.
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title:
const Text('Books'), // This is the title in the app bar.
pinned: true,
expandedHeight: 150.0,
// The "forceElevated" property causes the SliverAppBar to show
// a shadow. The "innerBoxIsScrolled" parameter is true when the
// inner scroll view is scrolled beyond its "zero" point, i.e.
// when it appears to be scrolled below the SliverAppBar.
// Without this, there are cases where the shadow would appear
// or not appear inappropriately, because the SliverAppBar is
// not actually aware of the precise position of the inner
// scroll views.
forceElevated: innerBoxIsScrolled,
bottom: TabBar(
// These are the widgets to put in each tab in the tab bar.
tabs: _tabs.map((String name) => Tab(text: name)).toList(),
),
),
),
];
},
body: TabBarView(
// These are the contents of the tab views, below the tabs.
children: _tabs.map((String name) {
return SafeArea(
top: false,
bottom: false,
child: Builder(
// This Builder is needed to provide a BuildContext that is
// "inside" the NestedScrollView, so that
// sliverOverlapAbsorberHandleFor() can find the
// NestedScrollView.
builder: (BuildContext context) {
return CustomScrollView(
// The "controller" and "primary" members should be left
// unset, so that the NestedScrollView can control this
// inner scroll view.
// If the "controller" property is set, then this scroll
// view will not be associated with the NestedScrollView.
// The PageStorageKey should be unique to this ScrollView;
// it allows the list to remember its scroll position when
// the tab view is not on the screen.
key: PageStorageKey<String>(name),
slivers: <Widget>[
SliverOverlapInjector(
// This is the flip side of the SliverOverlapAbsorber
// above.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
// In this example, the inner scroll view has
// fixed-height list items, hence the use of
// SliverFixedExtentList. However, one could use any
// sliver widget here, e.g. SliverList or SliverGrid.
sliver: SliverStickyHeader(
header: Text('header'),
sliver: SliverFixedExtentList(
// The items in this example are fixed to 48 pixels
// high. This matches the Material Design spec for
// ListTile widgets.
itemExtent: 48.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// This builder is called for each child.
// In this example, we just number each list item.
return ListTile(
title: Text('Item $index'),
);
},
// The childCount of the SliverChildBuilderDelegate
// specifies how many children this inner list
// has. In this example, each tab has a list of
// exactly 30 items, but this is arbitrary.
childCount: 30,
),
),
),
),
],
);
},
),
);
}).toList(),
),
),
),
);
}
}
I think the problem has something to do with the SliverOverlapInjector there is a workaround mentioned in this issue comment: https://github.com/flutter/flutter/issues/91972#issuecomment-995462622
Basically you need an improved overlap injector ( SliverPinnedOverlapInjector):
DISCLAIMER: The following source code is directly copied from the issue comment:
import 'dart:math';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class SliverPinnedOverlapInjector extends SingleChildRenderObjectWidget {
const SliverPinnedOverlapInjector({
required this.handle,
Key? key,
}) : super(key: key);
final SliverOverlapAbsorberHandle handle;
#override
RenderSliverPinnedOverlapInjector createRenderObject(BuildContext context) {
return RenderSliverPinnedOverlapInjector(
handle: handle,
);
}
#override
void updateRenderObject(
BuildContext context,
RenderSliverPinnedOverlapInjector renderObject,
) {
renderObject.handle = handle;
}
}
class RenderSliverPinnedOverlapInjector extends RenderSliver {
RenderSliverPinnedOverlapInjector({
required SliverOverlapAbsorberHandle handle,
}) : _handle = handle;
double? _currentLayoutExtent;
double? _currentMaxExtent;
SliverOverlapAbsorberHandle get handle => _handle;
SliverOverlapAbsorberHandle _handle;
set handle(SliverOverlapAbsorberHandle value) {
if (handle == value) return;
if (attached) {
handle.removeListener(markNeedsLayout);
}
_handle = value;
if (attached) {
handle.addListener(markNeedsLayout);
if (handle.layoutExtent != _currentLayoutExtent ||
handle.scrollExtent != _currentMaxExtent) markNeedsLayout();
}
}
#override
void attach(PipelineOwner owner) {
super.attach(owner);
handle.addListener(markNeedsLayout);
if (handle.layoutExtent != _currentLayoutExtent ||
handle.scrollExtent != _currentMaxExtent) markNeedsLayout();
}
#override
void detach() {
handle.removeListener(markNeedsLayout);
super.detach();
}
#override
void performLayout() {
_currentLayoutExtent = handle.layoutExtent;
final paintedExtent = min(
_currentLayoutExtent!,
constraints.remainingPaintExtent - constraints.overlap,
);
geometry = SliverGeometry(
paintExtent: paintedExtent,
maxPaintExtent: _currentLayoutExtent!,
maxScrollObstructionExtent: _currentLayoutExtent!,
paintOrigin: constraints.overlap,
scrollExtent: _currentLayoutExtent!,
layoutExtent: max(0, paintedExtent - constraints.scrollOffset),
hasVisualOverflow: paintedExtent < _currentLayoutExtent!,
);
}
}
Just copy the SliverPinnedOverlapInjector to your project and use it as a drop-in replacement for the SliverOverlapInjector. In my case this was the only change necessary. Everything else should stay the same.
Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
centerTitle: false,
automaticallyImplyLeading: false,
backgroundColor: Colors.black,
title: Text('Murder series (Event That Happen)'),
actions: <Widget>[
],
child: Container(
height: 20,
width: 20,
child: SvgPicture.asset(
'assets/library_pre.svg',
color: Colors.white,
),
),
),
),
],
expandedHeight: 200.0,
floating: true,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
child: Column(
children: [
SizedBox(
height: 70,
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
CircleAvatar(
radius: 40,
backgroundImage:
AssetImage('assets/forumPage.png'),
),
Text(
'74 Members',
style: TextStyle(
fontSize: 25,
color: Colors.white,
),
),
overlapped(),
Text(
'Leave',
style: TextStyle(
fontSize: 25,
color: Colors.red,
),
)
],
),
),
),
Container(
height: 30,
width: double.infinity,
decoration: BoxDecoration(color: Colors.red),
)
],
),
)),
),
];
},
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
],
),
],
),
))),
);
Related
I have a requirement to develop a screen where there is collapsible content to be achieved using sliver.
However, the height of collapsible content is dynamic and depends on the number of dynamic widgets applicable to the user (some may not have both dynamic widgets, some have one, some have both). These dynamic widgets load as parallel service to backend and not in sequential manner. Otherwise I would have calculated the height one by one.
Help would be appreciated since all examples on internet point to have a fixed header height for slivers
Example image attached of what i am trying to achieve.
Try with the silver appbar and make sure that your toolbarHeight is 0. Here I used just fixed height for a single element and the total height will be changed based on the number of elements or widgets you have.
import 'package:flutter/material.dart';
class DynamicAppbar extends StatefulWidget {
const DynamicAppbar({Key key}) : super(key: key);
#override
_DynamicAppbarState createState() => _DynamicAppbarState();
}
class _DynamicAppbarState extends State<DynamicAppbar> {
//set the height fixed for each widget
double fixedHeight = 50;
// replace with coming elements
List<String> items = [
"dynamicWidget1",
"dynamicWidget2",
"dynamicWidget3",
"dynamicWidget4",
];
#override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("My App Bar"),
),
body: DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
expandedHeight: fixedHeight * items.length,
floating: false,
pinned: true,
snap: false,
toolbarHeight: 0,
flexibleSpace: FlexibleSpaceBar(
background: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: List<Widget>.generate(
items.length,
(index) {
return Container(
height: fixedHeight,
child: Center(
child: Text(
items[index],
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
)),
);
},
),
),
),
),
SliverPersistentHeader(
delegate: _SliverAppBarDelegate(
TabBar(
labelColor: Colors.black87,
unselectedLabelColor: Colors.grey,
tabs: [
Tab(icon: Icon(Icons.info), text: "Tab 1"),
Tab(icon: Icon(Icons.lightbulb_outline), text: "Tab 2"),
],
),
),
pinned: true,
),
];
},
body: Center(
child: Text("Sample text"),
),
),
),
),
);
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate(this._tabBar);
final TabBar _tabBar;
#override
double get minExtent => _tabBar.preferredSize.height;
#override
double get maxExtent => _tabBar.preferredSize.height;
#override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(
child: _tabBar,
);
}
#override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return false;
}
}
Output:
Before and after scrolling
// this field is used for getting height of widget dynamically.
// you can set initial value as per your requirement.
var _cardSize = Size(Get.width, 300);
// this key is set to the widget of which we want to get size dynamically
GlobalKey _key = GlobalKey();
#override
void initState() {
super.initState();
//IMPORTANT---- this will be called once the build() method gets
// executed. By then we will have the widget rendered.
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_getSizeOfWidget();
});
}
//Get the size of the widget
_getSizeOfWidget() {
try {
RenderBox _cardBox = _key.currentContext.findRenderObject();
_cardSize = _cardBox.size;
if (!mounted) return;
setState(() {});
} catch (e) {}
}
//Set the key to the widget for which you need to set height dynamically
//IMPORTANT -- set the height value using _cardSize
Container(key: _key,child : //any child,height : _cardSize.height);
I hope this answers your question
I've created two tabs.
In each tab I have SingleChildScrollView wrapped with Scrollbar.
I can not have the primary scrollcontroller in both the tabs, because that throws me exception: "ScrollController attached to multiple scroll views."
For Tab ONE I use primary scrollcontroller, for Tab TWO I created Scrollcontroller and attached it.
For Tab ONE with primary scrollcontroller I can scroll both by keyboard and dragging scrollbar.
But for Tab TWO with non primary scrollcontroller, I have to scroll only by dragging scrollbar. This tab doesn't respond to keyboard page up /down keys.
Please check my code below. Guide me on how to achieve keyboard scrolling for Tab TWO.
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
home: TabExample(),
);
}
}
class TabExample extends StatefulWidget {
const TabExample({Key key}) : super(key: key);
#override
_TabExampleState createState() => _TabExampleState();
}
class _TabExampleState extends State<TabExample> {
ScrollController _scrollController;
#override
void initState() {
_scrollController = ScrollController();
super.initState();
}
#override
void dispose() {
_scrollController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: [
Tab(icon: Text('Tab ONE')),
Tab(icon: Text('Tab TWO')),
],
),
title: Text('Tabs Demo'),
),
body: TabBarView(
children: [
_buildWidgetA(),
_buildWidgetB(),
],
),
),
);
}
Widget _buildWidgetA() {
List<Widget> children = [];
for (int i = 0; i < 20; i++) {
children.add(
Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Container(
height: 100,
width: double.infinity,
color: Colors.black,
),
),
);
}
return Scrollbar(
isAlwaysShown: true,
showTrackOnHover: true,
child: SingleChildScrollView(
child: Column(
children: children,
),
),
);
}
Widget _buildWidgetB() {
List<Widget> children = [];
for (int i = 0; i < 20; i++) {
children.add(
Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Container(
height: 100,
width: double.infinity,
color: Colors.green,
),
),
);
}
return Scrollbar(
controller: _scrollController,
isAlwaysShown: true,
showTrackOnHover: true,
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
children: children,
),
),
);
}
}
You don't need to create an explicit ScrollController to achieve this.
One trick is to change which SingleChildScrollView is going to use the PrimaryScrollController whenever the Tab changes it's index.
So, when we listen that tab has changed to index 0, we will set that the first SingleChildScrolView is the primary one. When it changes to 1, we will set the other on as primary.
First create a new State variable like this,
int currentIndex = 0; // This will be the index of tab at a point in time
To listen to the change event, you need to add Listener to the TabController.
DefaultTabController(
length: 2,
child: Builder( // <---- Use a Builder Widget to get the context this this DefaultTabController
builder: (ctx) {
// Here we need to use ctx instead of context otherwise it will give null
final TabController tabController = DefaultTabController.of(ctx);
tabController.addListener(() {
if (!tabController.indexIsChanging) {
// When the tab has changed we are changing our currentIndex to the new index
setState(() => currentIndex = tabController.index);
}
});
return Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: [
Tab(icon: Text('Tab ONE')),
Tab(icon: Text('Tab TWO')),
],
),
title: Text('Tabs Demo'),
),
body: TabBarView(
children: [
_buildWidgetA(),
_buildWidgetB(),
],
),
);
},
),
);
Finally, depending on the currentIndex set primary: true to each SingleChildScrollView.
For _buildWidgetA,
Scrollbar(
isAlwaysShown: true,
showTrackOnHover: true,
child: SingleChildScrollView(
primary: currentIndex == 0, // <--- This will be primary if currentIndex = 0
child: Column(
children: children,
),
),
);
For _buildWidgetB,
Scrollbar(
isAlwaysShown: true,
showTrackOnHover: true,
child: SingleChildScrollView(
primary: currentIndex == 1, // <--- This will be primary if currentIndex = 1
child: Column(
children: children,
),
),
);
Now, you should be able to control both of the tabs with your keyboard.
Full code here
I want to place image and text into appbar, but it seems that i cant fit all i want into appbar.
I have this code:
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title:Column(
children: [
Container(
child: Image.asset(
'assets/vecteezy_circle-abstract_1191814.png',
fit: BoxFit.scaleDown,
height: 100,
),
),
Container(
padding: const EdgeInsets.only(top: 15),
child: Text('Text'))
],
),
bottom: TabBar(
tabs: [Tab(text: 'tab 1'), Tab(text: 'tab 2')],
),
),
body: TabBarView(children: [
Text("o"),
Text("kk"),
]),
));
}
and I have such result :
https://i.stack.imgur.com/jzt4o.png
how do I fit it right? so it looks like this:
https://i.stack.imgur.com/r0gum.png
You need to use CustomScrollView with SliverAppBar
look at this example from Flutter Dev
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
MyApp({Key key}) : super(key: key);
#override
Widget build(BuildContext context) {
final title = 'Floating App Bar';
return MaterialApp(
title: title,
home: Scaffold(
// No appbar provided to the Scaffold, only a body with a
// CustomScrollView.
body: CustomScrollView(
slivers: <Widget>[
// Add the app bar to the CustomScrollView.
SliverAppBar(
// Provide a standard title.
title: Text(title),
// Allows the user to reveal the app bar if they begin scrolling
// back up the list of items.
floating: true,
// Display a placeholder widget to visualize the shrinking size.
flexibleSpace: Placeholder(),
// Make the initial height of the SliverAppBar larger than normal.
expandedHeight: 200,
),
// Next, create a SliverList
SliverList(
// Use a delegate to build items as they're scrolled on screen.
delegate: SliverChildBuilderDelegate(
// The builder function returns a ListTile with a title that
// displays the index of the current item.
(context, index) => ListTile(title: Text('Item #$index')),
// Builds 1000 ListTiles
childCount: 1000,
),
),
],
),
),
);
}
}
I am trying to implement a collapsing toolbar with tabs. While scrolling through the content of the tab are not sticking to the bottom of the tab, rather it's scrolling up until the status bar.
I am not sure what exactly I am doing wrong. I'd like to get your help to solve this issue.
Here is the code snippet:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:mobile/src/data/model/order_response.dart';
import 'package:mobile/src/widget/order_view.dart';
class OrderDetailPage extends StatefulWidget {
final OrderResponse item;
OrderDetailPage(this.item);
#override
_OrderDetailPageState createState() => _OrderDetailPageState();
}
class _OrderDetailPageState extends State<OrderDetailPage> {
#override
Widget build(BuildContext context) {
return Scaffold(
body: DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
backgroundColor: Colors.black12,
elevation: 0.0,
expandedHeight: 230,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
background: Align(
alignment: Alignment.bottomCenter,
child: Container(
child: Column(
children: [
SizedBox(
height: 90,
),
OrderListItemView(widget.item, null)
],
),
),
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
TabBar(
labelColor: Colors.black87,
unselectedLabelColor: Colors.grey,
tabs: [
new Tab(text: "Instant Match"),
new Tab(text: "Requests"),
],
),
),
)
];
},
body: new TabBarView(
children: <Widget>[
Text(
'This content should stick to the bottom of tab bar',
style: TextStyle(fontSize: 24),
),
Text('This content should stick to the bottom of tab bar')
],
)),
),
);
}
void _onClickMenu(String value) {}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate(this._tabBar);
final TabBar _tabBar;
#override
double get minExtent => _tabBar.preferredSize.height;
#override
double get maxExtent => _tabBar.preferredSize.height;
#override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(
child: _tabBar,
);
}
#override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return false;
}
}
Current output:
In the desired output the content should stick to the bottom of tab bar.
I want to use create a sliver list view with a SliverAppBar such that when I scroll up the list, the icon inside the body shrinks to take place in the leading space of appBar.
The images here show something that I want to achieve. When I scroll up, the chart should move up and slide beside the title. (Something similar to Hero widget)
Till now, I tried SliverAppBar, but was not able to succeed. I am happy to use some other widget to achieve this. Thank you.
Have you tried with this?
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
//Variables needed to adapt FlexibleSpaceBar text color (title)
ScrollController _scrollController;
bool lastStatus = true;
double height = 200;
void _scrollListener() {
if (_isShrink != lastStatus) {
setState(() {
lastStatus = _isShrink;
});
}
}
bool get _isShrink {
return _scrollController.hasClients &&
_scrollController.offset > (height - kToolbarHeight);
}
#override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_scrollListener);
}
#override
void dispose() {
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
expandedHeight: height,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
centerTitle: true,
title: _isShrink
? Row(
children: [
//Replace container with your chart
// Here you can also use SizedBox in order to define a chart size
Container(
margin: EdgeInsets.all(10.0),
width: 30,
height: 30,
color: Colors.yellow),
Text('A little long title'),
],
)
: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'A little long title',
textAlign: TextAlign.center,
),
//Replace container with your chart
Container(
height: 80,
color: Colors.yellow,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Your chart here'),
],
),
),
]),
),
),
),
];
},
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return Container(
height: 40,
child: Text(index.toString()),
);
},
),
),
);
}
}
I don't think it can be done with SliverAppBar. You should search in Pub.dev for packages may this help you fluent_appbar