Flutter:让TabBarView内组件共享ScrollController并动态适配高度
我需要在包含顶部固定组件的Column里创建TabBarView,要求每个Tab能加载更多数据,切换Tab时保留上次的滚动位置。
基础组件结构
@override Widget build(BuildContext context) { super.build(context); return Container( color: Colors.orange, child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 顶部固定组件 Expanded( child: SingleChildScrollView( controller: _scrollController, child: Column( children: [ // 其他组件 TabBar( controller: _tabBarController, tabs: [ // Tab组件 ], ), TabBarView( controller: _tabBarController, children: [ // 可加载更多数据的组件 ], ), // 加载组件 ], ), ), ), ], ), ); }
遇到的错误
使用上述代码时抛出断言错误:
The following assertion was thrown during performLayout():
Assertion failed: file:///D:/Program/flutter/packages/flutter/lib/src/rendering/box.dart:1966:12
hasSize
"RenderBox was not laid out: RenderMouseRegion#af408 relayoutBoundary=up5 NEEDS-PAINT"
如果给TabBarView添加Expanded,会出现另一个错误:
The following assertion was thrown during performLayout():
Assertion failed: file:///D:/Program/flutter/packages/flutter/lib/src/rendering/box.dart:1966:12
hasSize
"RenderBox was not laid out: RenderRepaintBoundary#89637 relayoutBoundary=up1 NEEDS-PAINT NEEDS-COMPOSITING-BITS-UPDATE"
临时解决方案
目前只能通过状态管理手动设置TabBarView的高度,根据加载组件的尺寸调整,但这种方法不适用于无法提前获知尺寸的组件:
Obx(() => SizedBox( height: _currentImageTabIndex.value == 0 ? _imageGridHeight.value : _imageTagGridHeight.value, child: TabBarView(controller: _imageTabBarController, children: [ _imageListWidget, _imageTagListWidget, ]), )),
需求
希望无需手动设置高度,让TabBarView根据内部加载的组件动态调整高度,同时满足滚动加载和Tab切换保留位置的需求。
完整示例代码
import 'package:flutter/material.dart'; import 'package:issue_tabbarview_in_column/main/_share/dynamic_content_in_tab_widget.dart'; import 'package:issue_tabbarview_in_column/main/_share/fixed_top_widget.dart'; import 'package:issue_tabbarview_in_column/main/_share/title_widget.dart'; import 'package:issue_tabbarview_in_column/setting/page_path.dart'; class TemplatePage extends StatefulWidget { const TemplatePage({super.key}); @override State<TemplatePage> createState() => _TemplatePageState(); } class _TemplatePageState extends State<TemplatePage> with SingleTickerProviderStateMixin { final scrollController = ScrollController(); late final TabController _tabBarController; int _itemCount1 = 0; int _itemCount2 = 0; late final tabBarWidget = TabBar(controller: _tabBarController, tabs: const [ Tab(child: Icon(Icons.grid_on, color: Colors.black)), Tab(child: Icon(Icons.account_box_outlined, color: Colors.black)), ]); Future _onScrollDisable() async { await Future.delayed(Duration.zero); if (_itemCount1 != 0 && _itemCount2 != 0 && scrollController.position.maxScrollExtent != 0) return; _itemCount1 += 10; _itemCount2 += 10; setState(() {}); await _onScrollDisable(); } void _onScrollMax() { if (scrollController.position.pixels == scrollController.position.maxScrollExtent) _tabBarController.index == 0 ? _itemCount1 += 10 : _itemCount2 += 10; setState(() {}); print("_itemCount1: $_itemCount1"); print("_itemCount2: $_itemCount2"); } @override void initState() { super.initState(); _tabBarController = TabController(length: 2, vsync: this); _onScrollDisable().then((_) => scrollController.addListener(_onScrollMax)); } @override void dispose() { _tabBarController.dispose(); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final tabBarViewWidget = TabBarView(controller: _tabBarController, children: [ DynamicContentInTabWidget(itemCount: _itemCount1), DynamicContentInTabWidget(itemCount: _itemCount2), ]); return Scaffold( appBar: AppBar(title: InkWell(onTap: () => Navigator.pushNamed(context, PagePath.index), child: const Text("Template Page"))), body: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const FixedTopWidget(), Expanded( child: SingleChildScrollView( controller: scrollController, child: Column( children: [ const TitleWidget(), tabBarWidget, SizedBox(height: 1000, child: tabBarViewWidget), // 希望移除SizedBox,实现高度自适应 const CircularProgressIndicator(), ], ), ), ), ], ))); } } class TitleWidget extends StatelessWidget { const TitleWidget({super.key}); @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, color: Colors.green, width: double.infinity, height: 400, child: const Text("Title Widget"), ); } } class FixedTopWidget extends StatelessWidget { const FixedTopWidget({super.key}); @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, color: Colors.red, width: double.infinity, height: 100, child: const Text("Fixed Top Widget"), ); } } class DynamicContentInTabWidget extends StatelessWidget { final int itemCount; final bool showLoading; const DynamicContentInTabWidget({super.key, required this.itemCount, this.showLoading = false}); @override Widget build(BuildContext context) { return GridView.builder( physics: const NeverScrollableScrollPhysics(), itemCount: showLoading ? itemCount + 1 : itemCount, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 1.0, crossAxisSpacing: 1, mainAxisSpacing: 1), itemBuilder: (BuildContext context, int index) { if (showLoading && index == itemCount) const Center(child: CircularProgressIndicator()); return Container( alignment: Alignment.center, color: Colors.primaries[index % Colors.primaries.length], height: 100, child: Text('grid item $index'), ); }, ); } }
解决方案
核心思路
TabBarView本身需要确定尺寸才能完成布局,嵌套在SingleChildScrollView中时无法自动获取内部内容高度。解决关键在于让Tab内的列表组件自适应高度,并保留Tab状态避免切换时重置内容。
修改后的关键代码
- 给Tab内的列表组件添加状态保留能力
将DynamicContentInTabWidget改为StatefulWidget,混入AutomaticKeepAliveClientMixin以保留Tab状态:
class DynamicContentInTabWidget extends StatefulWidget { final int itemCount; final bool showLoading; const DynamicContentInTabWidget({super.key, required this.itemCount, this.showLoading = false}); @override State<DynamicContentInTabWidget> createState() => _DynamicContentInTabWidgetState(); } class _DynamicContentInTabWidgetState extends State<DynamicContentInTabWidget> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); // 必须调用以保证状态保留 return GridView.builder( shrinkWrap: true, // 关键:让GridView根据内容自适应高度 physics: const NeverScrollableScrollPhysics(), itemCount: widget.showLoading ? widget.itemCount + 1 : widget.itemCount, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 1.0, crossAxisSpacing: 1, mainAxisSpacing: 1), itemBuilder: (BuildContext context, int index) { if (widget.showLoading && index == widget.itemCount) { return const Center(child: CircularProgressIndicator()); } return Container( alignment: Alignment.center, color: Colors.primaries[index % Colors.primaries.length], height: 100, child: Text('grid item $index'), ); }, ); } }
- 调整TabBar和TabBarView的布局
用PreferredSize包裹TabBar,确保其在Column中布局正常;同时移除固定高度的SizedBox:
@override Widget build(BuildContext context) { final tabBarViewWidget = TabBarView(controller: _tabBarController, children: [ DynamicContentInTabWidget(itemCount: _itemCount1), DynamicContentInTabWidget(itemCount: _itemCount2), ]); return Scaffold( appBar: AppBar(title: InkWell(onTap: () => Navigator.pushNamed(context, PagePath.index), child: const Text("Template Page"))), body: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const FixedTopWidget(), Expanded( child: SingleChildScrollView( controller: scrollController, child: Column( children: [ const TitleWidget(), // 用PreferredSize包裹TabBar,明确其高度 PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), child: tabBarWidget, ), tabBarViewWidget, const CircularProgressIndicator(), ], ), ), ), ], ))); }
说明
- shrinkWrap: true:让GridView根据子组件总高度确定自身高度,使TabBarView能获取到内容的实际尺寸,无需手动设置高度。
- AutomaticKeepAliveClientMixin:通过
wantKeepAlive: true保证Tab切换时不销毁子组件,从而保留加载状态和滚动位置。 - PreferredSize包裹TabBar:TabBar需要明确的高度约束,用PreferredSize包裹后可在Column中正常布局,避免出现约束错误。
内容的提问来源于stack exchange,提问作者Sittiphan Sittisak

