TabBarView 页面无法正确重建

TabBarView page not rebuilding correctly

我试图通过读取 TabController 的索引在 TabBarView 的每一页上显示标签编号。但出于某种原因,即使在日志中打印了正确的值,该值似乎也没有在视觉上正确更新。

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: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? _tabController;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(
      length: 3,
      vsync: this,
    );
  }

  _back() {
    if (_tabController!.index > 0) {
      _tabController!.animateTo(_tabController!.index - 1);

      setState(() {});
    }
  }

  _next() {
    if (_tabController!.index < _tabController!.length - 1) {
      _tabController!.animateTo(_tabController!.index + 1);

      setState(() {});
    }
  }

  Widget _tab(int index) {
    var value = "Page $index:   ${_tabController!.index + 1} / ${_tabController!.length}";
    print(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: const Text("Back"),
        ),
        Text(value,
          style: const TextStyle(
          ),
        ),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: TabBarView(
        controller: _tabController,
        children: [
          _tab(1),
          _tab(2),
          _tab(3),
        ],
      )
    );
  }
}

当从索引 0 导航到索引 1 时,日志中会按预期打印以下内容:

I/flutter (25730): Page 1:   2 / 3
I/flutter (25730): Page 2:   2 / 3
I/flutter (25730): Page 3:   2 / 3

然而实际显示的却是Page 2: 1 / 3

我试过使用 UniqueKey 以及在下一帧调用 setState,但这没有什么区别。使用硬编码延迟调用 setState 似乎可行,但似乎也是错误的。

考虑到在调用 setState 时重建了所有选项卡,为什么日志中打印的内容与显示的内容不同?假设它与构成 TabBarViewPageView/Scrollable/Viewport 小部件有关,但到底发生了什么?请注意,即使从第 1 页转到第 2 页,然后转到第 3 页,none 任何页面上的值都在更新,因此即使是屏幕上的小部件也无法正确重建。

来自 flutter animatedTo 方法说明的文档:animatedTo 立即设置索引和上一个索引,然后从当前值到索引播放动画。

调用 _tab 方法后,return来自它的小部件现在位于小部件树中。

每当构建方法再次 运行 时,它的外观就会改变。

每次调用 _tab 方法时,不 return 小部件的代码部分再次 运行s,并且 return 小部件已经在小部件树中. 但是需要再次运行构建方法才能更改小部件。

首次构建小部件时调用构建。但是之后需要重新运行 build 方法 with setState.

我将您的选项卡导航按钮转换为小部件 class。与_tab方法和Widget的对比我们可以更容易理解class.

当从索引 0 导航到索引 1、2、3 时,日志中会打印以下内容: 颤振:第 0 页:1 / 3 颤振:第 0 页:1 / 3

import 'package:flutter/material.dart';

void main() {
  runApp(const TestMyApp());
}

class TestMyApp extends StatelessWidget {
  const TestMyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  TabController? _tabController;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(
      length: 3,
      vsync: this,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Column(
          children: [
            Expanded(
              child: TabBarView(
                controller: _tabController,
                children: [
                  _tab(1),
                  _tab(2),
                  _tab(3),
                ],
              ),
            ),
            Expanded(
                child: TabNavigationWidget(tabController: _tabController!)),
          ],
        ));
  }

  Widget _tab(int index) {
    return Text('$index');
  }
}

class TabNavigationWidget extends StatelessWidget {
  TabController tabController;

  TabNavigationWidget({Key? key, required this.tabController})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    var value = "Page :   ${tabController.index + 1} / ${tabController.length}";
    print(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: Text("Back ${tabController.index}"),
        ),
        Text(
          value,
          style: const TextStyle(),
        ),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  _back() {
    if (tabController.index > 0) {
      tabController.animateTo(tabController.index - 1);
    }
  }

  _next() {
    if (tabController.index < tabController.length - 1) {
      tabController.animateTo(tabController.index + 1);
    }
  }
}

我建议您使用小部件 classes 而不是 _tab() 方法。当调用 setState 方法时,您的 _tab 方法构建了 3 次。

切换页面时可以使用Stream监听tab索引变化。换页时更新索引。

final _tabPageIndicator = StreamController<int>.broadcast();
Stream<int> get getTabPage => _tabPageIndicator.stream;

...

// Update tab index on Stream
_tabPageIndicator.sink.add(_tabController!.index + 1);

然后使用 StreamBuilder,当它正在侦听的 Stream 发生变化时,它会重建。无需使用 setState() 在 StreamBuilder 中重建 Widgets。

StreamBuilder<int>(
  stream: getTabPage,
  builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
    if (snapshot.hasData && snapshot.data != null) {
      tabIndex = snapshot.data!;
    }
    return Text(
      'Page $index: [$tabIndex / ${_tabController!.length}]',
      style: const TextStyle(),
    );
 }
),

完整样本

import 'dart:async';

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: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? _tabController;
  int tabIndex = 1;

  final _tabPageIndicator = StreamController<int>.broadcast();
  Stream<int> get getTabPage => _tabPageIndicator.stream;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(
      length: 3,
      vsync: this,
    );

    // Update tab index on Stream
    _tabPageIndicator.sink.add(_tabController!.index + 1);
  }

  @override
  void dispose() {
    super.dispose();

    // Close Stream when not in use
    _tabPageIndicator.close();
  }

  _back() {
    if (_tabController!.index > 0) {
      _tabController!.animateTo(_tabController!.index - 1);

      // setState(() {
      // });

      // Update tab index on Stream
      _tabPageIndicator.sink.add(_tabController!.index + 1);
    }
  }

  _next() {
    if (_tabController!.index < _tabController!.length - 1) {
      _tabController!.animateTo(_tabController!.index + 1);

      // setState(() {
      // });

      // Update tab index on Stream
      _tabPageIndicator.sink.add(_tabController!.index + 1);
    }
  }

  Widget _tab(int index) {
    var value =
        "Page $index:   ${_tabController!.index + 1} / ${_tabController!.length}";
    debugPrint(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: const Text("Back"),
        ),
        // StreamBuilder rebuilds every time there's a change on Stream
        StreamBuilder<int>(
            stream: getTabPage,
            builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
              if (snapshot.hasData && snapshot.data != null) {
                tabIndex = snapshot.data!;
              }
              return Text(
                'Page $index: [$tabIndex / ${_tabController!.length}]',
                style: const TextStyle(),
              );
            }),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: TabBarView(
          controller: _tabController,
          children: [
            _tab(1),
            _tab(2),
            _tab(3),
          ],
        ));
  }
}

我了解的太少widget tree欢迎指正和更新答案

所有选项卡最初都在构建,而 _tabController!.index0_next 方法会等待 animateTo 完成动画,然后调用 setState。使用 setState 重建 build 下的 UI 但 TabBarView 不会重建,直到我们告诉它它正在发生变化。

widget tree is smart enough while updating the UI. -

在创建 widget, without providing key 时,它会生成 objectRuntimeType 密钥,并且在调用 setState.

时不会更改(与提供密钥相同)

虽然在这里,更新取决于键,并且 TabBarViewTabBarView 的 widget tree(key) 没有什么不同,但我认为我什么都没发生,我们看不到任何更新UI.

接下来是 adding listener

Register a closure to be called when the object changes.

我们可以在 TabController 上添加侦听器以侦听更改并在 setState 内添加侦听器以更新 UI。您还可以从 _back_next 方法中删除 setState

_tabController = TabController(
  length: 3,
  vsync: this,
)..addListener(() {
    setState(() {});
  });

或者只是

Use index instead of _tabController!.index while both responsibility is same inside Row.

我终于可以回答我自己的问题了。 _TabBarViewState 的内部逻辑解释了这种奇怪的行为。 TabBarView 在内部使用 PageView,它根据 TabController 索引的变化进行动画处理。这是该逻辑的一个片段:

final int previousIndex = _controller!.previousIndex;
if ((_currentIndex! - previousIndex).abs() == 1) {
  _warpUnderwayCount += 1;
  await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease);
  _warpUnderwayCount -= 1;
  return Future<void>.value();
}

请注意,它会跟踪是否正在使用 _warpUnderwayCount 变量进行动画处理,一旦我们在 TabController.

此外,_TabBarViewState 维护一个 _children 代表每个页面的小部件列表,该列表首先在 TabBarView 初始化时创建,以后只能由 _TabBarViewState 本身通过调用其 _updateChildren() 函数:

void _updateChildren() {
  _children = widget.children;
  _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children);
}

_TabBarViewState 也覆盖了 didUpdateWidget 函数的默认行为:

@override
void didUpdateWidget(TabBarView oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.controller != oldWidget.controller)
    _updateTabController();
  if (widget.children != oldWidget.children && _warpUnderwayCount == 0)
    _updateChildren();
}

请注意,即使我们通过在 animateTo() 之后调用 setState() 从我们的父有状态小部件提供新的 children 列表,children 的列表将是被 TabBarView 忽略,因为 _warpUnderwayCountdidUpdateWidget 被调用时的值为 1,因此 _updateChildren() 不会被调用内部逻辑如上所示。

我认为这是 TabBarView 小部件的一个限制,它与其内部 PageView 以及可选的 TabBar 小部件协调方面的复杂性有关它与它共享 TabController.

就解决方案而言,考虑到通过更新其 Key 来重建整个 TabBarView 会取消动画,并且通过调用 setState() 设置新的 children如果在页面更改动画仍然是 运行 时调用 animateTo() 被忽略,我只能考虑在保存重建 children 所需的所有变量后调用 setState()before animateTo() 在下一帧调用。如果在同一帧内调用,children 仍然不会更新,因为 didUpdateWidget 仍然会在动画开始后被调用。这是我的问题的代码,更新了建议的解决方案:

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: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  TabController? _tabController;
  int _newIndex = 0;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(
      length: 3,
      vsync: this,
    );
  }

  _back() {
    if (_tabController!.index > 0) {
      _newIndex = _tabController!.index - 1;

      setState(() {});

      WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
        _tabController!.animateTo(_newIndex);
      });
    }
  }

  _next() {
    if (_tabController!.index < _tabController!.length - 1) {
      _newIndex = _tabController!.index + 1;

      setState(() {});

      WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
        _tabController!.animateTo(_newIndex);
      });
    }
  }

  Widget _tab(int index) {
    var value = "Page $index:   ${_newIndex + 1} / ${_tabController!.length}";
    print(value);

    return Row(
      children: [
        TextButton(
          onPressed: _back,
          child: const Text("Back"),
        ),
        Text(value,
          style: const TextStyle(
          ),
        ),
        TextButton(
          onPressed: _next,
          child: const Text("Next"),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: TabBarView(
          controller: _tabController,
          children: [
            _tab(1),
            _tab(2),
            _tab(3),
          ],
        )
    );
  }
}