You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter:让TabBarView内组件共享ScrollController并动态适配高度

问题:Column中嵌套TabBarView实现动态高度+保留滚动位置

我需要在包含顶部固定组件的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状态避免切换时重置内容。

修改后的关键代码

  1. 给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'),
        );
      },
    );
  }
}
  1. 调整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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 01:25:56