Reputation: 312
I'm trying to nest a tabview in a Scrollview, and can't find a good way to accomplish the task.
A diagram is included below:
The desired functionality is to have a normal scrollable page, where one of the slivers is a tab view with different sized (and dynamically resizing) tabs.
Unfortunately, despite looking at several resources and the flutter docs, I haven't come across any good solutions.
Here is what I have tried:
SingleChildScrollView
with a column child, with the TabBarView wrapped in an IntrinsicHeight widget (Unbound constraints)CustomScrollView
variations, with the TabBarView wrapped in a SliverFillRemaining
and the header and footer each wrapped with a SliverToBoxAdapter
. In all cases, the content is forced to expand to the full size of the viewport (as if using a SliverFillViewport
Sliver with a viewport fraction of 1.0) if smaller, or a nested scroll/overflow is created within the space if larger (see below)
NestedScrollView
comes closest but still suffers the ill effects of the previous implementation (see below for code example)AnimatedSwitcher
in conjunction with a listener on the TabBar to animate between the "tabs" but this wasn't swipable and the animation janked and the switched widgets overlapped)The thus-far "best" implementation's code is given below, but it is not ideal.
Does anyone know of any way(s) to accomplish this?
Thank you in advance.
// best (more "Least-bad") solution code
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Demo',
routes: {
'root': (context) => const Scaffold(
body: ExamplePage(),
),
},
initialRoute: 'root',
);
}
}
class ExamplePage extends StatefulWidget {
const ExamplePage({
Key? key,
}) : super(key: key);
@override
State<ExamplePage> createState() => _ExamplePageState();
}
class _ExamplePageState extends State<ExamplePage>
with TickerProviderStateMixin {
late TabController tabController;
@override
void initState() {
super.initState();
tabController = TabController(length: 2, vsync: this);
tabController.addListener(() {
setState(() {});
});
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: Colors.grey[100],
appBar: AppBar(),
body: NestedScrollView(
floatHeaderSlivers: false,
physics: const AlwaysScrollableScrollPhysics(),
headerSliverBuilder: (BuildContext context, bool value) => [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 24.0,
top: 32.0,
),
child: Column(
children: [
// TODO: Add scan tab thing
Container(
height: 94.0,
width: double.infinity,
color: Colors.blueGrey,
alignment: Alignment.center,
child: Text('A widget with information'),
),
const SizedBox(height: 24.0),
GenaricTabBar(
controller: tabController,
tabStrings: const [
'Tab 1',
'Tab 2',
],
),
],
),
),
),
],
body: CustomScrollView(
slivers: [
SliverFillRemaining(
child: TabBarView(
physics: const AlwaysScrollableScrollPhysics(),
controller: tabController,
children: [
// Packaging Parts
SingleChildScrollView(
child: Container(
height: 200,
color: Colors.black,
),
),
// Symbols
SingleChildScrollView(
child: Column(
children: [
Container(
color: Colors.red,
height: 200.0,
),
Container(
color: Colors.orange,
height: 200.0,
),
Container(
color: Colors.amber,
height: 200.0,
),
Container(
color: Colors.green,
height: 200.0,
),
Container(
color: Colors.blue,
height: 200.0,
),
Container(
color: Colors.purple,
height: 200.0,
),
],
),
),
],
),
),
SliverToBoxAdapter(
child: ElevatedButton(
child: Text('Button'),
onPressed: () => print('pressed'),
),
),
],
),
),
);
}
class GenaricTabBar extends StatelessWidget {
final TabController? controller;
final List<String> tabStrings;
const GenaricTabBar({
Key? key,
this.controller,
required this.tabStrings,
}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8.0),
),
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// if want tab-bar, uncomment
TabBar(
controller: controller,
indicator: ShapeDecoration.fromBoxDecoration(
BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Colors.white,
),
),
tabs: tabStrings
.map((String s) => _GenaricTab(tabString: s))
.toList(),
),
],
),
);
}
class _GenaricTab extends StatelessWidget {
final String tabString;
const _GenaricTab({
Key? key,
required this.tabString,
}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
child: Text(
tabString,
style: const TextStyle(
color: Colors.black,
),
),
height: 32.0,
alignment: Alignment.center,
);
}
The above works in Dartpad (dartpad.dev) and doesn't require any external libraries
Upvotes: 1
Views: 2182
Reputation: 312
Ideally, there is a better answer out there somewhere. BUT, until it arrives, this is how I got around the issue:
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Demo',
// darkTheme: Themes.darkTheme,
// Language support
// Routes will keep track of all of the possible places to go.
routes: {
'root': (context) => const Scaffold(
body: ExamplePage(),
),
},
initialRoute: 'root', // See below.
);
}
}
class ExamplePage extends StatefulWidget {
const ExamplePage({
Key? key,
}) : super(key: key);
@override
State<ExamplePage> createState() => _ExamplePageState();
}
class _ExamplePageState extends State<ExamplePage>
with TickerProviderStateMixin {
late TabController tabController;
late PageController scrollController;
late int _pageIndex;
@override
void initState() {
super.initState();
_pageIndex = 0;
tabController = TabController(length: 2, vsync: this);
scrollController = PageController();
tabController.addListener(() {
if (_pageIndex != tabController.index) {
animateToPage(tabController.index);
}
});
}
void animateToPage([int? target]) {
if (target == null || target == _pageIndex) return;
scrollController.animateToPage(
target,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
setState(() {
_pageIndex = target;
});
}
void animateTabSelector([int? target]) {
if (target == null || target == tabController.index) return;
tabController.animateTo(
target,
duration: const Duration(
milliseconds: 100,
),
);
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: Colors.grey[100],
appBar: AppBar(),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 24.0,
top: 32.0,
),
child: Column(
children: [
// TODO: Add scan tab thing
Container(
height: 94.0,
width: double.infinity,
color: Colors.blueGrey,
alignment: Alignment.center,
child: Text('A widget with information'),
),
const SizedBox(height: 24.0),
GenaricTabBar(
controller: tabController,
tabStrings: const [
'Tab 1',
'Tab 2',
],
),
],
),
),
),
SliverToBoxAdapter(
child: Container(
height: 200,
color: Colors.black,
),
),
SliverToBoxAdapter(
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
// if page more than 50% to other page, animate tab controller
double diff = notification.metrics.extentBefore -
notification.metrics.extentAfter;
if (diff.abs() < 50 && !tabController.indexIsChanging) {
animateTabSelector(diff >= 0 ? 1 : 0);
}
if (notification.metrics.atEdge) {
if (notification.metrics.extentBefore == 0.0) {
// Page 0 (1)
if (_pageIndex != 0) {
setState(() {
_pageIndex = 0;
});
animateTabSelector(_pageIndex);
}
} else if (notification.metrics.extentAfter == 0.0) {
// Page 1 (2)
if (_pageIndex != 1) {
setState(() {
_pageIndex = 1;
});
animateTabSelector(_pageIndex);
}
}
}
return false;
},
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
physics: const PageScrollPhysics(),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Parts
SizedBox(
width: MediaQuery.of(context).size.width,
child: Container(
color: Colors.teal,
height: 50,
),
),
// 2. Symbols
SizedBox(
width: MediaQuery.of(context).size.width,
child: Container(
color: Colors.orange,
height: 10000,
),
),
],
),
),
),
),
SliverToBoxAdapter(
child: Column(
children: [
Container(
color: Colors.red,
height: 200.0,
),
Container(
color: Colors.orange,
height: 200.0,
),
Container(
color: Colors.amber,
height: 200.0,
),
Container(
color: Colors.green,
height: 200.0,
),
Container(
color: Colors.blue,
height: 200.0,
),
Container(
color: Colors.purple,
height: 200.0,
),
],
),
),
],
),
);
}
class GenaricTabBar extends StatelessWidget {
final TabController? controller;
final List<String> tabStrings;
const GenaricTabBar({
Key? key,
this.controller,
required this.tabStrings,
}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8.0),
),
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// if want tab-bar, uncomment
TabBar(
controller: controller,
indicator: ShapeDecoration.fromBoxDecoration(
BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Colors.white,
),
),
tabs: tabStrings
.map((String s) => _GenaricTab(tabString: s))
.toList(),
),
],
),
);
}
class _GenaricTab extends StatelessWidget {
final String tabString;
const _GenaricTab({
Key? key,
required this.tabString,
}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
child: Text(
tabString,
style: const TextStyle(
color: Colors.black,
),
),
height: 32.0,
alignment: Alignment.center,
);
}
(Dartpad ready)
The basic idea is to not use a Tabview at all and instead use a horizontal scroll view nested in our scrollable area.
By using page physics for the horizontal scroll and using a PageController instead of a normal ScrollController, we can achieve a a scroll effect between the two widgets in the horizontal area that snap to whichever page is correct.
By using a notification listener, we can listen for changes in the scrollview and update the tab view accordingly.
The above code assumes only two tabs, so would require more thought to optimize for more tabs, particularly in the NotificationListener function.
This also may not be performant for large tabs since both tabs are being built, even if one is out of view.
Finally, the vertical height of each tab is the same; so a tab that is much larger will cause the other tab to have a lot of empty vertical space.
Hope this helps anyone in a similar boat, and am open to suggestions to improve.
Upvotes: 1