Использование представления вкладок с динамическим размером, вложенного в представление прокрутки во Flutter
Я пытаюсь вложить вкладку в вид прокрутки и не могу найти хороший способ выполнить эту задачу.
Диаграмма приведена ниже:
Желаемая функциональность состоит в том, чтобы иметь обычную прокручиваемую страницу, где одна из частей представляет собой представление вкладок с вкладками разного размера (и с динамическим изменением размера).
К сожалению, несмотря на просмотр нескольких ресурсов и документации по флаттеру, я не нашел хороших решений.
Вот что я пробовал:
-
SingleChildScrollView
с дочерним столбцом, с TabBarView, обернутым в виджет IntrinsicHeight (неограниченные ограничения) -
CustomScrollView
варианты, с TabBarView, завернутым вSliverFillRemaining
и верхний и нижний колонтитулы, каждый из которых обернутSliverToBoxAdapter
. Во всех случаях содержимое принудительно расширяется до полного размера окна просмотра (как при использованииSliverFillViewport
Щепка с долей области просмотра 1,0), если меньше, или вложенная прокрутка/переполнение создается в пространстве, если больше (см. ниже)- Если дочерние элементы TabBarView являются прокручиваемыми виджетами, полоска с панелью вкладок имеет высоту, равную ViewPort (1.0), а любое оставшееся пространство пусто.
- Если дочерние элементы не прокручиваются, они принудительно расширяются, чтобы соответствовать размеру, если они меньше, или выдают ошибку переполнения, если они больше.
-
NestedScrollView
подходит ближе всего, но все еще страдает от негативных последствий предыдущей реализации (см. ниже пример кода) - Различные другие неортодоксальные подходы (такие как удаление TabBarView и попытка использовать
AnimatedSwitcher
в сочетании с прослушивателем на TabBar для анимации между «вкладками», но это нельзя было перелистывать, анимация дергалась, а переключаемые виджеты перекрывались)
Пока что «лучший» код реализации приведен ниже, но он не идеален.
Кто-нибудь знает какой-либо способ (ы) для достижения этого?
Заранее спасибо.
// 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,
);
}
Вышеприведенное работает в Dartpad (dartpad.dev) и не требует никаких внешних библиотек.
1 ответ
В идеале, где-то есть лучший ответ. НО, пока он не прибыл, вот как я обошел проблему:
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,
);
}
(Дартпад готов)
Основная идея состоит в том, чтобы вообще не использовать Tabview, а вместо этого использовать представление с горизонтальной прокруткой, вложенное в нашу прокручиваемую область.
Используя физику страницы для горизонтальной прокрутки и используя PageController вместо обычного ScrollController, мы можем добиться эффекта прокрутки между двумя виджетами в горизонтальной области, которые привязываются к нужной странице.
Используя прослушиватель уведомлений, мы можем прослушивать изменения в прокрутке и соответствующим образом обновлять представление вкладки.
ОГРАНИЧЕНИЯ:
Приведенный выше код предполагает наличие только двух вкладок, поэтому для оптимизации большего количества вкладок потребуется больше усилий, особенно в функции NotificationListener.
Это также может быть неэффективным для больших вкладок, поскольку строятся обе вкладки, даже если одна из них находится вне поля зрения.
Наконец, вертикальная высота каждой вкладки одинакова; поэтому вкладка, которая намного больше, приведет к тому, что на другой вкладке будет много пустого вертикального пространства.
Надеюсь, что это поможет любому в подобной лодке, и я открыт для предложений по улучшению.