Использование представления вкладок с динамическим размером, вложенного в представление прокрутки во 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.

Это также может быть неэффективным для больших вкладок, поскольку строятся обе вкладки, даже если одна из них находится вне поля зрения.

Наконец, вертикальная высота каждой вкладки одинакова; поэтому вкладка, которая намного больше, приведет к тому, что на другой вкладке будет много пустого вертикального пространства.

Надеюсь, что это поможет любому в подобной лодке, и я открыт для предложений по улучшению.

Другие вопросы по тегам