Parcourir la source

vertical tab bar

zhaoyadi il y a 3 ans
Parent
commit
235d3e4b1d

+ 149 - 0
example/lib/demo/demo1.dart

@@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:luojigou_thinking_core/luojigou_thinking_core.dart';
+
+class Demo1 extends StatefulWidget {
+  const Demo1({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<StatefulWidget> createState() => _Demo1State();
+}
+
+class _Demo1State extends State<Demo1> with SingleTickerProviderStateMixin {
+  late TabController _controller;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = TabController(length: 5, vsync: this);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        SizedBox(
+          width: 55,
+          height: double.infinity,
+          child: ColoredBox(
+            color: Color(0xFFC8DDFF),
+            child: Column(
+              children: [
+                SizedBox(
+                  width: double.infinity,
+                  height: 44 + MediaQuery.of(context).padding.top,
+                  child: const ColoredBox(
+                    color: Colors.teal,
+                  ),
+                ),
+                Expanded(
+                  child: VerticalTabBar(
+                    controller: _controller,
+                    isScrollable: true,
+                    labelStyle: const TextStyle(
+                      color: Color(0xFF75AAFF),
+                      fontSize: 16,
+                      height: 1,
+                    ),
+                    unselectedLabelStyle: const TextStyle(
+                      color: Color(0xFFFFFFFF),
+                      fontSize: 16,
+                      height: 1,
+                    ),
+                    labelColor: const Color(0xFF75AAFF),
+                    unselectedLabelColor: Colors.white,
+                    indicator: _TabDecoration(),
+                    tabs: [
+                      Container(
+                        padding: EdgeInsets.symmetric(horizontal: 4),
+                        height: double.infinity,
+                        color: Colors.transparent,
+                        child: Center(child: const Text('综合区')),
+                      ),
+                      Container(
+                        padding: EdgeInsets.symmetric(horizontal: 4),
+                        height: double.infinity,
+                        color: Colors.transparent,
+                        child: Center(child: const Text('中华文化')),
+                      ),
+                      Container(
+                        padding: EdgeInsets.symmetric(horizontal: 4),
+                        height: double.infinity,
+                        color: Colors.transparent,
+                        child: Center(child: const Text('科学探索')),
+                      ),
+                      Container(
+                        padding: EdgeInsets.symmetric(horizontal: 4),
+                        height: double.infinity,
+                        color: Colors.transparent,
+                        child: Center(child: const Text('自由探索')),
+                      ),
+                      Container(
+                        padding: EdgeInsets.symmetric(horizontal: 4),
+                        height: double.infinity,
+                        color: Colors.transparent,
+                        child: Center(child: const Text('思维探索')),
+                      ),
+                    ],
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+        Expanded(
+          child: GestureDetector(
+            onTap: () {
+              int lastIndex = _controller.index;
+              int nextIndex = (lastIndex + 1) % _controller.length;
+              _controller.animateTo(nextIndex);
+            },
+            child: ColoredBox(
+              color: Colors.transparent,
+              child: SizedBox.expand(),
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _TabDecoration extends Decoration {
+  @override
+  BoxPainter createBoxPainter([VoidCallback? onChanged]) {
+    return _BoxPainter(onChanged);
+  }
+}
+
+class _BoxPainter extends BoxPainter {
+  _BoxPainter(VoidCallback? onChanged) : super(onChanged);
+
+  @override
+  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
+    if (configuration.size == null) return;
+
+    final size = configuration.size!;
+    final paint = Paint()..color = const Color(0xFF75AAFF);
+
+    final dotx = offset.dx + size.width / 2;
+    final doty = offset.dy + size.height - 8;
+    canvas.drawCircle(Offset(dotx, doty), 2, paint);
+
+    double h = 18.8;
+    double w = 60;
+
+    final path = Path();
+    path.moveTo(dotx - w, doty + 8);
+    path.lineTo(dotx, doty + 8 + 20);
+    path.lineTo(dotx + w, doty + 8);
+
+    path.reset();
+    path.moveTo(dotx - w, doty + 8);
+    path.cubicTo(dotx - w/2-10, doty+8, dotx-w/2+10, doty + 8 + h, dotx, doty + 8 + h);
+    path.cubicTo(dotx + w/2-10, doty+8+h, dotx+w/2+10, doty + 8 , dotx + w, doty + 8);
+    canvas.drawPath(path, paint..color = const Color(0xFFC8DDFF));
+  }
+}

+ 201 - 0
example/lib/demo/demo2.dart

@@ -0,0 +1,201 @@
+import 'dart:ui';
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+class Demo2 extends StatefulWidget {
+  const Demo2({Key? key}) : super(key: key);
+
+  @override
+  State<Demo2> createState() => _Demo2State();
+}
+
+class _Demo2State extends State<Demo2> {
+  late ScrollController _scrollController;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        ColoredBox(
+          color: const Color(0xFFC8DDFF),
+          child: SizedBox(
+            width: 55,
+            child: ListView.builder(
+              itemBuilder: (context, index) {
+                return CustomPaint(
+                  painter: index == 2 ? _Painter() : null,
+                  child: SizedBox(
+                    width: double.infinity,
+                    height: 100,
+                    child: Center(child: Text("Hello $index")),
+                  ),
+                );
+              },
+            ),
+          ),
+        ),
+        Expanded(
+          child: Column(
+            children: [
+              Expanded(
+                child: Container(
+                  color: Colors.yellow,
+                ),
+              ),
+              const Expanded(
+                child: TestWidget(
+                  child: TestWidget(),
+                ),
+              ),
+              Expanded(
+                child: Container(
+                  color: Colors.lightGreen,
+                ),
+              ),
+              Expanded(
+                child: Container(
+                  color: Colors.redAccent,
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+const double _width = 18.74;
+const double _height = 158.41;
+
+class _Painter extends CustomPainter {
+  @override
+  void paint(Canvas canvas, Size size) {
+    canvas.drawCircle(
+      Offset(size.width - 5 - 2, size.height / 2),
+      2,
+      Paint()..color = Colors.green,
+    );
+
+    var path = Path();
+    path.reset();
+    path.moveTo(size.width, size.height / 2 - 20);
+    path.lineTo(size.width + 20, size.height / 2);
+    path.lineTo(size.width, size.height / 2 + 20);
+    path.close();
+
+    canvas.drawPath(
+      path,
+      Paint()
+        ..color = Colors.blue
+        ..style = PaintingStyle.fill,
+    );
+    // canvas.drawRect(Offset.zero & size, Paint()..color = Colors.green);
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+}
+
+class TestWidget extends SingleChildRenderObjectWidget {
+  const TestWidget({
+    Key? key,
+    Widget? child,
+  }) : super(key: key, child: child);
+
+  @override
+  RenderObject createRenderObject(BuildContext context) {
+    return _RenderTest();
+  }
+}
+
+class _RenderTest extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
+  @override
+  void performLayout() {
+    child?.layout(constraints.deflate(const EdgeInsets.all(20)));
+    size = constraints.constrain(Size.infinite);
+  }
+
+  @override
+  Size computeDryLayout(BoxConstraints constraints) {
+    return constraints.constrain(Size.infinite);
+
+    Listener listener;
+  }
+
+  @override
+  void paint(PaintingContext context, Offset offset) {
+    super.paint(context, offset);
+    Color color;
+    if (parent is _RenderTest) {
+      if (_touchOffset != null) {
+        color = Colors.black;
+      } else {
+        color = Colors.red;
+      }
+    } else {
+      if (_touchOffset != null) {
+        color = Colors.tealAccent;
+      } else {
+        color = Colors.deepPurple;
+      }
+    }
+
+    context.canvas.drawRect(offset & size, Paint()
+      ..color = color);
+    if (child != null) {
+      context.paintChild(child!, offset);
+    }
+  }
+
+  @override
+  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+    if(child != null){
+      return child!.hitTest(result, position: position);
+    }
+    return super.hitTestChildren(result, position: position);
+  }
+
+  @override
+  bool hitTestSelf(Offset position) => size.contains(position);
+
+  Offset? _touchOffset;
+
+  GestureRecognizer gestureDetector = TapGestureRecognizer()
+    ..onTap = () {
+      debugPrint("TapGestureRecognizer onTap");
+    }
+    ..onTapDown = (details) {
+      debugPrint("TapGestureRecognizer onTapDown");
+    }
+    ..onTapUp = (details) {
+      debugPrint("TapGestureRecognizer onTapUp");
+    }
+    ..onTapCancel = () {
+      debugPrint("TapGestureRecognizer onTapCancel");
+    };
+
+  @override
+  void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
+    debugPrint(event.runtimeType.toString());
+    debugPrint(event.position.toString());
+    if (event is PointerDownEvent) {
+      _touchOffset = event.localPosition;
+      gestureDetector.addPointer(event);
+    } else if (event is PointerMoveEvent) {
+      _touchOffset = event.localPosition;
+    } else {
+      _touchOffset = null;
+    }
+
+    markNeedsPaint();
+  }
+}

+ 1 - 8
lib/luojigou_thinking_core.dart

@@ -1,13 +1,6 @@
-///
+/// 逻辑狗思维芯模块代码库
 library luojigou_thinking_core;
 
-import 'dao.dart';
-import 'data.dart';
-import 'model.dart';
-import 'page.dart';
-import 'view.dart';
-import 'widget.dart';
-
 export 'dao.dart';
 export 'data.dart';
 export 'model.dart';

+ 5 - 0
lib/src/widget/radar_chart.dart

@@ -33,6 +33,11 @@ class _RenderRadarChart extends RenderBox {
     return constraints.constrain(Size.infinite);
   }
 
+  @override
+  bool hitTestSelf(Offset position) {
+    return size.contains(position);
+  }
+
   @override
   void paint(PaintingContext context, Offset offset) {
     super.paint(context, offset);

+ 1352 - 0
lib/src/widget/vertical_tab_bar.dart

@@ -0,0 +1,1352 @@
+import 'dart:math' as math;
+import 'dart:ui' show lerpDouble;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart' show DragStartBehavior;
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+import 'package:vector_math/vector_math_64.dart' show Vector4;
+
+const double _kTabHeight = 46.0;
+const double _kTextAndIconTabHeight = 72.0;
+
+class _IndicatorPainter extends CustomPainter {
+  _IndicatorPainter({
+    required this.controller,
+    required this.indicator,
+    required this.indicatorSize,
+    required this.tabKeys,
+    required _IndicatorPainter? old,
+    required this.indicatorPadding,
+  })  : assert(controller != null),
+        assert(indicator != null),
+        super(repaint: controller.animation) {
+    if (old != null) {
+      saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
+    }
+  }
+
+  final TabController controller;
+  final Decoration indicator;
+  final TabBarIndicatorSize? indicatorSize;
+  final EdgeInsetsGeometry indicatorPadding;
+  final List<GlobalKey> tabKeys;
+
+  // _currentTabOffsets and _currentTextDirection are set each time TabBar
+  // layout is completed. These values can be null when TabBar contains no
+  // tabs, since there are nothing to lay out.
+  List<double>? _currentTabOffsets;
+  TextDirection? _currentTextDirection;
+
+  Rect? _currentRect;
+  BoxPainter? _painter;
+  bool _needsPaint = false;
+
+  void markNeedsPaint() {
+    _needsPaint = true;
+  }
+
+  void dispose() {
+    _painter?.dispose();
+  }
+
+  void saveTabOffsets(List<double>? tabOffsets, TextDirection? textDirection) {
+    _currentTabOffsets = tabOffsets;
+    _currentTextDirection = textDirection;
+  }
+
+  // _currentTabOffsets[index] is the offset of the start edge of the tab at index, and
+  // _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab.
+  int get maxTabIndex => _currentTabOffsets!.length - 2;
+
+  double centerOf(int tabIndex) {
+    assert(_currentTabOffsets != null);
+    assert(_currentTabOffsets!.isNotEmpty);
+    assert(tabIndex >= 0);
+    assert(tabIndex <= maxTabIndex);
+    return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) /
+        2.0;
+  }
+
+  Rect indicatorRect(Size tabBarSize, int tabIndex) {
+    assert(_currentTabOffsets != null);
+    assert(_currentTextDirection != null);
+    assert(_currentTabOffsets!.isNotEmpty);
+    assert(tabIndex >= 0);
+    assert(tabIndex <= maxTabIndex);
+    double tabLeft, tabRight;
+    switch (_currentTextDirection!) {
+      case TextDirection.rtl:
+        tabLeft = _currentTabOffsets![tabIndex + 1];
+        tabRight = _currentTabOffsets![tabIndex];
+        break;
+      case TextDirection.ltr:
+        tabLeft = _currentTabOffsets![tabIndex];
+        tabRight = _currentTabOffsets![tabIndex + 1];
+        break;
+    }
+
+    if (indicatorSize == TabBarIndicatorSize.label) {
+      final double tabWidth = tabKeys[tabIndex].currentContext!.size!.width;
+      final double delta = ((tabRight - tabLeft) - tabWidth) / 2.0;
+      tabLeft += delta;
+      tabRight -= delta;
+    }
+
+    final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection);
+    final Rect rect =
+        Rect.fromLTWH(tabLeft, 0.0, tabRight - tabLeft, tabBarSize.height);
+
+    if (!(rect.size >= insets.collapsedSize)) {
+      throw FlutterError(
+        'indicatorPadding insets should be less than Tab Size\n'
+        'Rect Size : ${rect.size}, Insets: ${insets.toString()}',
+      );
+    }
+    return insets.deflateRect(rect);
+  }
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    _needsPaint = false;
+    _painter ??= indicator.createBoxPainter(markNeedsPaint);
+
+    final double index = controller.index.toDouble();
+    final double value = controller.animation!.value;
+    final bool ltr = index > value;
+    final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex);
+    final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex);
+    final Rect fromRect = indicatorRect(size, from);
+    final Rect toRect = indicatorRect(size, to);
+    _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
+    assert(_currentRect != null);
+
+    final ImageConfiguration configuration = ImageConfiguration(
+      size: _currentRect!.size,
+      textDirection: _currentTextDirection,
+    );
+    _painter!.paint(canvas, _currentRect!.topLeft, configuration);
+  }
+
+  @override
+  bool shouldRepaint(_IndicatorPainter old) {
+    return _needsPaint ||
+        controller != old.controller ||
+        indicator != old.indicator ||
+        tabKeys.length != old.tabKeys.length ||
+        (!listEquals(_currentTabOffsets, old._currentTabOffsets)) ||
+        _currentTextDirection != old._currentTextDirection;
+  }
+}
+
+class _TabStyle extends AnimatedWidget {
+  const _TabStyle({
+    Key? key,
+    required Animation<double> animation,
+    required this.selected,
+    required this.labelColor,
+    required this.unselectedLabelColor,
+    required this.labelStyle,
+    required this.unselectedLabelStyle,
+    required this.child,
+  }) : super(key: key, listenable: animation);
+
+  final TextStyle? labelStyle;
+  final TextStyle? unselectedLabelStyle;
+  final bool selected;
+  final Color? labelColor;
+  final Color? unselectedLabelColor;
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    final ThemeData themeData = Theme.of(context);
+    final TabBarTheme tabBarTheme = TabBarTheme.of(context);
+    final Animation<double> animation = listenable as Animation<double>;
+
+    // To enable TextStyle.lerp(style1, style2, value), both styles must have
+    // the same value of inherit. Force that to be inherit=true here.
+    final TextStyle defaultStyle = (labelStyle ??
+            tabBarTheme.labelStyle ??
+            themeData.primaryTextTheme.bodyText1!)
+        .copyWith(inherit: true);
+    final TextStyle defaultUnselectedStyle = (unselectedLabelStyle ??
+            tabBarTheme.unselectedLabelStyle ??
+            labelStyle ??
+            themeData.primaryTextTheme.bodyText1!)
+        .copyWith(inherit: true);
+    final TextStyle textStyle = selected
+        ? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)!
+        : TextStyle.lerp(
+            defaultUnselectedStyle, defaultStyle, animation.value)!;
+
+    final Color selectedColor = labelColor ??
+        tabBarTheme.labelColor ??
+        themeData.primaryTextTheme.bodyText1!.color!;
+    final Color unselectedColor = unselectedLabelColor ??
+        tabBarTheme.unselectedLabelColor ??
+        selectedColor.withAlpha(0xB2); // 70% alpha
+    final Color color = selected
+        ? Color.lerp(selectedColor, unselectedColor, animation.value)!
+        : Color.lerp(unselectedColor, selectedColor, animation.value)!;
+
+    return DefaultTextStyle(
+      style: textStyle.copyWith(color: color),
+      child: IconTheme.merge(
+        data: IconThemeData(
+          size: 24.0,
+          color: color,
+        ),
+        child: child,
+      ),
+    );
+  }
+}
+
+typedef _LayoutCallback = void Function(
+    List<double> xOffsets, TextDirection textDirection, double width);
+
+class _TabLabelBarRenderer extends RenderFlex {
+  _TabLabelBarRenderer({
+    List<RenderBox>? children,
+    required Axis direction,
+    required MainAxisSize mainAxisSize,
+    required MainAxisAlignment mainAxisAlignment,
+    required CrossAxisAlignment crossAxisAlignment,
+    required TextDirection textDirection,
+    required VerticalDirection verticalDirection,
+    required this.onPerformLayout,
+  })  : assert(onPerformLayout != null),
+        assert(textDirection != null),
+        super(
+          children: children,
+          direction: direction,
+          mainAxisSize: mainAxisSize,
+          mainAxisAlignment: mainAxisAlignment,
+          crossAxisAlignment: crossAxisAlignment,
+          textDirection: textDirection,
+          verticalDirection: verticalDirection,
+        );
+
+  _LayoutCallback onPerformLayout;
+
+  @override
+  void performLayout() {
+    super.performLayout();
+    // xOffsets will contain childCount+1 values, giving the offsets of the
+    // leading edge of the first tab as the first value, of the leading edge of
+    // the each subsequent tab as each subsequent value, and of the trailing
+    // edge of the last tab as the last value.
+    RenderBox? child = firstChild;
+    final List<double> xOffsets = <double>[];
+    while (child != null) {
+      final FlexParentData childParentData =
+          child.parentData! as FlexParentData;
+      xOffsets.add(childParentData.offset.dx);
+      assert(child.parentData == childParentData);
+      child = childParentData.nextSibling;
+    }
+    assert(textDirection != null);
+    switch (textDirection!) {
+      case TextDirection.rtl:
+        xOffsets.insert(0, size.width);
+        break;
+      case TextDirection.ltr:
+        xOffsets.add(size.width);
+        break;
+    }
+    onPerformLayout(xOffsets, textDirection!, size.width);
+  }
+}
+
+class _TabLabelBar extends Flex {
+  _TabLabelBar({
+    Key? key,
+    List<Widget> children = const <Widget>[],
+    required this.onPerformLayout,
+  }) : super(
+          key: key,
+          children: children,
+          direction: Axis.horizontal,
+          mainAxisSize: MainAxisSize.max,
+          mainAxisAlignment: MainAxisAlignment.start,
+          crossAxisAlignment: CrossAxisAlignment.center,
+          verticalDirection: VerticalDirection.down,
+        );
+
+  final _LayoutCallback onPerformLayout;
+
+  @override
+  RenderFlex createRenderObject(BuildContext context) {
+    return _TabLabelBarRenderer(
+      direction: direction,
+      mainAxisAlignment: mainAxisAlignment,
+      mainAxisSize: mainAxisSize,
+      crossAxisAlignment: crossAxisAlignment,
+      textDirection: getEffectiveTextDirection(context)!,
+      verticalDirection: verticalDirection,
+      onPerformLayout: onPerformLayout,
+    );
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, _TabLabelBarRenderer renderObject) {
+    super.updateRenderObject(context, renderObject);
+    renderObject.onPerformLayout = onPerformLayout;
+  }
+}
+
+double _indexChangeProgress(TabController controller) {
+  final double controllerValue = controller.animation!.value;
+  final double previousIndex = controller.previousIndex.toDouble();
+  final double currentIndex = controller.index.toDouble();
+
+  // The controller's offset is changing because the user is dragging the
+  // TabBarView's PageView to the left or right.
+  if (!controller.indexIsChanging)
+    return (currentIndex - controllerValue).abs().clamp(0.0, 1.0);
+
+  // The TabController animation's value is changing from previousIndex to currentIndex.
+  return (controllerValue - currentIndex).abs() /
+      (currentIndex - previousIndex).abs();
+}
+
+class _ChangeAnimation extends Animation<double>
+    with AnimationWithParentMixin<double> {
+  _ChangeAnimation(this.controller);
+
+  final TabController controller;
+
+  @override
+  Animation<double> get parent => controller.animation!;
+
+  @override
+  void removeStatusListener(AnimationStatusListener listener) {
+    if (controller.animation != null) super.removeStatusListener(listener);
+  }
+
+  @override
+  void removeListener(VoidCallback listener) {
+    if (controller.animation != null) super.removeListener(listener);
+  }
+
+  @override
+  double get value => _indexChangeProgress(controller);
+}
+
+class _DragAnimation extends Animation<double>
+    with AnimationWithParentMixin<double> {
+  _DragAnimation(this.controller, this.index);
+
+  final TabController controller;
+  final int index;
+
+  @override
+  Animation<double> get parent => controller.animation!;
+
+  @override
+  void removeStatusListener(AnimationStatusListener listener) {
+    if (controller.animation != null) super.removeStatusListener(listener);
+  }
+
+  @override
+  void removeListener(VoidCallback listener) {
+    if (controller.animation != null) super.removeListener(listener);
+  }
+
+  @override
+  double get value {
+    assert(!controller.indexIsChanging);
+    final double controllerMaxValue = (controller.length - 1).toDouble();
+    final double controllerValue =
+        controller.animation!.value.clamp(0.0, controllerMaxValue);
+    return (controllerValue - index.toDouble()).abs().clamp(0.0, 1.0);
+  }
+}
+
+// This class, and TabBarScrollController, only exist to handle the case
+// where a scrollable TabBar has a non-zero initialIndex. In that case we can
+// only compute the scroll position's initial scroll offset (the "correct"
+// pixels value) after the TabBar viewport width and scroll limits are known.
+class _TabBarScrollPosition extends ScrollPositionWithSingleContext {
+  _TabBarScrollPosition({
+    required ScrollPhysics physics,
+    required ScrollContext context,
+    required ScrollPosition? oldPosition,
+    required this.tabBar,
+  }) : super(
+          physics: physics,
+          context: context,
+          initialPixels: null,
+          oldPosition: oldPosition,
+        );
+
+  final _VerticalTabBarState tabBar;
+
+  bool? _initialViewportDimensionWasZero;
+
+  @override
+  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
+    bool result = true;
+    if (_initialViewportDimensionWasZero != true) {
+      // If the viewport never had a non-zero dimension, we just want to jump
+      // to the initial scroll position to avoid strange scrolling effects in
+      // release mode: In release mode, the viewport temporarily may have a
+      // dimension of zero before the actual dimension is calculated. In that
+      // scenario, setting the actual dimension would cause a strange scroll
+      // effect without this guard because the super call below would starts a
+      // ballistic scroll activity.
+      assert(viewportDimension != null);
+      _initialViewportDimensionWasZero = viewportDimension != 0.0;
+      correctPixels(tabBar._initialScrollOffset(
+          viewportDimension, minScrollExtent, maxScrollExtent));
+      result = false;
+    }
+    return super.applyContentDimensions(minScrollExtent, maxScrollExtent) &&
+        result;
+  }
+}
+
+// This class, and TabBarScrollPosition, only exist to handle the case
+// where a scrollable TabBar has a non-zero initialIndex.
+class _TabBarScrollController extends ScrollController {
+  _TabBarScrollController(this.tabBar);
+
+  final _VerticalTabBarState tabBar;
+
+  @override
+  ScrollPosition createScrollPosition(ScrollPhysics physics,
+      ScrollContext context, ScrollPosition? oldPosition) {
+    return _TabBarScrollPosition(
+      physics: physics,
+      context: context,
+      oldPosition: oldPosition,
+      tabBar: tabBar,
+    );
+  }
+}
+
+class _TabItemWrapper extends SingleChildRenderObjectWidget {
+  const _TabItemWrapper({
+    Key? key,
+    required Widget child,
+  }) : super(key: key, child: child);
+
+  @override
+  RenderObject createRenderObject(BuildContext context) {
+    return _RenderTabItemWrapper();
+  }
+}
+
+class _RenderTabItemWrapper extends RenderBox
+    with RenderObjectWithChildMixin<RenderBox> {
+  int _itemCount = 0;
+
+  @override
+  void attach(covariant PipelineOwner owner) {
+    super.attach(owner);
+    assert(() {
+      _itemCount += 1;
+      return true;
+    }());
+  }
+
+  @override
+  void adoptChild(covariant RenderObject child) {
+    super.adoptChild(child);
+    assert(() {
+      _itemCount += 1;
+      return true;
+    }());
+  }
+
+  @override
+  void dropChild(covariant RenderObject child) {
+    assert(() {
+      _itemCount -= 1;
+      return true;
+    }());
+
+    super.dropChild(child);
+  }
+
+  @override
+  void detach() {
+    assert(() {
+      _itemCount -= 1;
+      return true;
+    }());
+    super.detach();
+  }
+
+  @override
+  Size computeDryLayout(BoxConstraints constraints) {
+    assert(_itemCount > 0);
+
+    if (child == null) return Size.zero;
+    return ChildLayoutHelper.dryLayoutChild(child!, constraints);
+  }
+
+  @override
+  void performLayout() {
+    assert(_itemCount > 0);
+    if (child == null) size = Size.zero;
+
+    child!.layout(constraints, parentUsesSize: true);
+    size = child!.size;
+  }
+
+  @override
+  bool hitTest(BoxHitTestResult result, {required Offset position}) {
+    return child?.hitTest(result, position: position) ?? false;
+  }
+
+  @override
+  void paint(PaintingContext context, Offset offset) {
+    super.paint(context, offset);
+    if (child == null) return;
+    context.paintChild(child!, offset);
+  }
+}
+
+class _TabWrapper extends SingleChildRenderObjectWidget {
+  const _TabWrapper({
+    Key? key,
+    required Widget child,
+  }) : super(key: key, child: child);
+
+  @override
+  RenderObject createRenderObject(BuildContext context) {
+    return _RenderTabWrapper();
+  }
+}
+
+class _RenderTabWrapper extends RenderBox
+    with RenderObjectWithChildMixin<RenderBox> {
+  bool _attached = false;
+  bool _hasAdoptChild = false;
+
+  @override
+  void attach(covariant PipelineOwner owner) {
+    super.attach(owner);
+    assert(!_attached);
+    assert(() {
+      _attached = true;
+      return true;
+    }());
+    if (_attached) {
+      if (child != null) {
+        debugValidateChild(child!);
+        assert(() {
+          _hasAdoptChild = true;
+          return true;
+        }());
+      }
+    }
+  }
+
+  @override
+  void detach() {
+    assert(_attached);
+    assert(() {
+      _attached = false;
+      return true;
+    }());
+    if (_hasAdoptChild) {
+      assert(() {
+        _hasAdoptChild = false;
+        return true;
+      }());
+    }
+    super.detach();
+  }
+
+  @override
+  double computeMinIntrinsicWidth(double height) {
+    if (child == null) return 0.0;
+    return super.computeMinIntrinsicWidth(height);
+  }
+
+  @override
+  double computeMinIntrinsicHeight(double width) {
+    if (child == null) return 0.0;
+    return super.computeMinIntrinsicHeight(width);
+  }
+
+  @override
+  double computeMaxIntrinsicWidth(double height) {
+    if (child == null) return 1.0;
+    return super.computeMaxIntrinsicWidth(height);
+  }
+
+  @override
+  double computeMaxIntrinsicHeight(double width) {
+    if (child == null) return 1.0;
+    return super.computeMaxIntrinsicHeight(width);
+  }
+
+  @override
+  Size computeDryLayout(BoxConstraints constraints) {
+    if (child == null) {
+      return Size.zero;
+    }
+
+    return ChildLayoutHelper.dryLayoutChild(child!, constraints).flipped;
+  }
+
+  @override
+  void performLayout() {
+    if (child == null) {
+      size = Size.zero;
+    } else {
+      child!.layout(
+        constraints.flipped,
+        parentUsesSize: true,
+      );
+      size = constraints.constrain(child!.size.flipped);
+    }
+  }
+
+  @override
+  bool hitTestSelf(Offset position) => size.contains(position);
+
+  @override
+  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+    var offset = Offset(
+      size.width - position.dy,
+      size.height - position.dx,
+    );
+
+    return child!.hitTest(result, position: offset);
+  }
+
+  final LayerHandle<TransformLayer> _layerHandle =
+      LayerHandle<TransformLayer>();
+
+  @override
+  void paint(PaintingContext context, Offset offset) {
+    super.paint(context, offset);
+
+    if (_layerHandle.layer != null && _layerHandle.layer is TransformLayer) {
+      context.pushTransform(
+        needsCompositing,
+        offset,
+        _getTransform(),
+        _paint,
+        oldLayer: _layerHandle.layer!,
+      );
+    } else {
+      _layerHandle.layer = context.pushTransform(
+        needsCompositing,
+        offset,
+        _getTransform(),
+        _paint,
+      );
+    }
+  }
+
+  void _paint(PaintingContext context, Offset offset) {
+    context.paintChild(child!, offset);
+  }
+
+  Matrix4 _getTransform() {
+    double cdc = 2 * (constraints.minWidth + 1);
+    double csc = constraints.minHeight - constraints.minHeight - 1;
+
+    Matrix4 m1 = Matrix4.zero();
+    Matrix4 m2 = Matrix4.zero();
+
+    m1.row0 = Vector4(cdc, 0, 0, 0);
+    m1.row1 = Vector4(0, cdc, 0, 0);
+    m1.row2 = Vector4(0, 0, csc, 0);
+    m1.row3 = Vector4(0, 0, 0, csc);
+
+    m2.row3 = Vector4(0, 0, 0, -1);
+    m2.row2 = Vector4(0, 0, -1, 0);
+    m2.row1 = Vector4(0, 0.5, 0, 0);
+    m2.row0 = Vector4(0.5, 0, 0, 0);
+
+    Matrix4 m = m1 * m2;
+    double a = m.trace() / 4, b = a / 16;
+
+    m.rotateZ(-math.pi / 2);
+    m.translate(-size.height, 0, 0);
+
+    m.multiply(Matrix4(-a, a, a, a, a, -a, a, a, a, a, -a, a, a, a, a, -a));
+    m.multiply(Matrix4(-b, b, b, b, b, -b, b, b, b, b, -b, b, b, b, b, -b));
+    return m;
+  }
+}
+
+class VerticalTabBar extends StatefulWidget implements PreferredSizeWidget {
+  /// Creates a material design tab bar.
+  ///
+  /// The [tabs] argument must not be null and its length must match the [controller]'s
+  /// [TabController.length].
+  ///
+  /// If a [TabController] is not provided, then there must be a
+  /// [DefaultTabController] ancestor.
+  ///
+  /// The [indicatorWeight] parameter defaults to 2, and must not be null.
+  ///
+  /// The [indicatorPadding] parameter defaults to [EdgeInsets.zero], and must not be null.
+  ///
+  /// If [indicator] is not null or provided from [TabBarTheme],
+  /// then [indicatorWeight], [indicatorPadding], and [indicatorColor] are ignored.
+  const VerticalTabBar({
+    Key? key,
+    required this.tabs,
+    this.controller,
+    this.isScrollable = false,
+    this.padding,
+    this.indicatorColor,
+    this.automaticIndicatorColorAdjustment = true,
+    this.indicatorWeight = 2.0,
+    this.indicatorPadding = EdgeInsets.zero,
+    this.indicator,
+    this.indicatorSize,
+    this.labelColor,
+    this.labelStyle,
+    this.labelPadding,
+    this.unselectedLabelColor,
+    this.unselectedLabelStyle,
+    this.dragStartBehavior = DragStartBehavior.start,
+    this.overlayColor,
+    this.mouseCursor,
+    this.enableFeedback,
+    this.onTap,
+    this.physics,
+  })  : assert(tabs != null),
+        assert(isScrollable != null),
+        assert(dragStartBehavior != null),
+        assert(indicator != null ||
+            (indicatorWeight != null && indicatorWeight > 0.0)),
+        assert(indicator != null || (indicatorPadding != null)),
+        super(key: key);
+
+  /// Typically a list of two or more [Tab] widgets.
+  ///
+  /// The length of this list must match the [controller]'s [TabController.length]
+  /// and the length of the [TabBarView.children] list.
+  final List<Widget> tabs;
+
+  /// This widget's selection and animation state.
+  ///
+  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
+  /// will be used.
+  final TabController? controller;
+
+  /// Whether this tab bar can be scrolled horizontally.
+  ///
+  /// If [isScrollable] is true, then each tab is as wide as needed for its label
+  /// and the entire [VerticalTabBar] is scrollable. Otherwise each tab gets an equal
+  /// share of the available space.
+  final bool isScrollable;
+
+  /// The amount of space by which to inset the tab bar.
+  ///
+  /// When [isScrollable] is false, this will yield the same result as if you had wrapped your
+  /// [VerticalTabBar] in a [Padding] widget. When [isScrollable] is true, the scrollable itself is inset,
+  /// allowing the padding to scroll with the tab bar, rather than enclosing it.
+  final EdgeInsetsGeometry? padding;
+
+  /// The color of the line that appears below the selected tab.
+  ///
+  /// If this parameter is null, then the value of the Theme's indicatorColor
+  /// property is used.
+  ///
+  /// If [indicator] is specified or provided from [TabBarTheme],
+  /// this property is ignored.
+  final Color? indicatorColor;
+
+  /// The thickness of the line that appears below the selected tab.
+  ///
+  /// The value of this parameter must be greater than zero and its default
+  /// value is 2.0.
+  ///
+  /// If [indicator] is specified or provided from [TabBarTheme],
+  /// this property is ignored.
+  final double indicatorWeight;
+
+  /// Padding for indicator.
+  /// This property will now no longer be ignored even if indicator is declared
+  /// or provided by [TabBarTheme]
+  ///
+  /// For [isScrollable] tab bars, specifying [kTabLabelPadding] will align
+  /// the indicator with the tab's text for [Tab] widgets and all but the
+  /// shortest [Tab.text] values.
+  ///
+  /// The default value of [indicatorPadding] is [EdgeInsets.zero].
+  final EdgeInsetsGeometry indicatorPadding;
+
+  /// Defines the appearance of the selected tab indicator.
+  ///
+  /// If [indicator] is specified or provided from [TabBarTheme],
+  /// the [indicatorColor], and [indicatorWeight] properties are ignored.
+  ///
+  /// The default, underline-style, selected tab indicator can be defined with
+  /// [UnderlineTabIndicator].
+  ///
+  /// The indicator's size is based on the tab's bounds. If [indicatorSize]
+  /// is [TabBarIndicatorSize.tab] the tab's bounds are as wide as the space
+  /// occupied by the tab in the tab bar. If [indicatorSize] is
+  /// [TabBarIndicatorSize.label], then the tab's bounds are only as wide as
+  /// the tab widget itself.
+  final Decoration? indicator;
+
+  /// Whether this tab bar should automatically adjust the [indicatorColor].
+  ///
+  /// If [automaticIndicatorColorAdjustment] is true,
+  /// then the [indicatorColor] will be automatically adjusted to [Colors.white]
+  /// when the [indicatorColor] is same as [Material.color] of the [Material] parent widget.
+  final bool automaticIndicatorColorAdjustment;
+
+  /// Defines how the selected tab indicator's size is computed.
+  ///
+  /// The size of the selected tab indicator is defined relative to the
+  /// tab's overall bounds if [indicatorSize] is [TabBarIndicatorSize.tab]
+  /// (the default) or relative to the bounds of the tab's widget if
+  /// [indicatorSize] is [TabBarIndicatorSize.label].
+  ///
+  /// The selected tab's location appearance can be refined further with
+  /// the [indicatorColor], [indicatorWeight], [indicatorPadding], and
+  /// [indicator] properties.
+  final TabBarIndicatorSize? indicatorSize;
+
+  /// The color of selected tab labels.
+  ///
+  /// Unselected tab labels are rendered with the same color rendered at 70%
+  /// opacity unless [unselectedLabelColor] is non-null.
+  ///
+  /// If this parameter is null, then the color of the [ThemeData.primaryTextTheme]'s
+  /// bodyText1 text color is used.
+  final Color? labelColor;
+
+  /// The color of unselected tab labels.
+  ///
+  /// If this property is null, unselected tab labels are rendered with the
+  /// [labelColor] with 70% opacity.
+  final Color? unselectedLabelColor;
+
+  /// The text style of the selected tab labels.
+  ///
+  /// If [unselectedLabelStyle] is null, then this text style will be used for
+  /// both selected and unselected label styles.
+  ///
+  /// If this property is null, then the text style of the
+  /// [ThemeData.primaryTextTheme]'s bodyText1 definition is used.
+  final TextStyle? labelStyle;
+
+  /// The padding added to each of the tab labels.
+  ///
+  /// If there are few tabs with both icon and text and few
+  /// tabs with only icon or text, this padding is vertically
+  /// adjusted to provide uniform padding to all tabs.
+  ///
+  /// If this property is null, then kTabLabelPadding is used.
+  final EdgeInsetsGeometry? labelPadding;
+
+  /// The text style of the unselected tab labels.
+  ///
+  /// If this property is null, then the [labelStyle] value is used. If [labelStyle]
+  /// is null, then the text style of the [ThemeData.primaryTextTheme]'s
+  /// bodyText1 definition is used.
+  final TextStyle? unselectedLabelStyle;
+
+  /// Defines the ink response focus, hover, and splash colors.
+  ///
+  /// If non-null, it is resolved against one of [MaterialState.focused],
+  /// [MaterialState.hovered], and [MaterialState.pressed].
+  ///
+  /// [MaterialState.pressed] triggers a ripple (an ink splash), per
+  /// the current Material Design spec. The [overlayColor] doesn't map
+  /// a state to [InkResponse.highlightColor] because a separate highlight
+  /// is not used by the current design guidelines. See
+  /// https://material.io/design/interaction/states.html#pressed
+  ///
+  /// If the overlay color is null or resolves to null, then the default values
+  /// for [InkResponse.focusColor], [InkResponse.hoverColor], [InkResponse.splashColor]
+  /// will be used instead.
+  final MaterialStateProperty<Color?>? overlayColor;
+
+  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
+  final DragStartBehavior dragStartBehavior;
+
+  /// The cursor for a mouse pointer when it enters or is hovering over the
+  /// individual tab widgets.
+  ///
+  /// If this property is null, [SystemMouseCursors.click] will be used.
+  final MouseCursor? mouseCursor;
+
+  /// Whether detected gestures should provide acoustic and/or haptic feedback.
+  ///
+  /// For example, on Android a tap will produce a clicking sound and a long-press
+  /// will produce a short vibration, when feedback is enabled.
+  ///
+  /// Defaults to true.
+  final bool? enableFeedback;
+
+  /// An optional callback that's called when the [VerticalTabBar] is tapped.
+  ///
+  /// The callback is applied to the index of the tab where the tap occurred.
+  ///
+  /// This callback has no effect on the default handling of taps. It's for
+  /// applications that want to do a little extra work when a tab is tapped,
+  /// even if the tap doesn't change the TabController's index. TabBar [onTap]
+  /// callbacks should not make changes to the TabController since that would
+  /// interfere with the default tap handler.
+  final ValueChanged<int>? onTap;
+
+  /// How the [VerticalTabBar]'s scroll view should respond to user input.
+  ///
+  /// For example, determines how the scroll view continues to animate after the
+  /// user stops dragging the scroll view.
+  ///
+  /// Defaults to matching platform conventions.
+  final ScrollPhysics? physics;
+
+  /// A size whose height depends on if the tabs have both icons and text.
+  ///
+  /// [AppBar] uses this size to compute its own preferred size.
+  @override
+  Size get preferredSize {
+    double maxHeight = _kTabHeight;
+    for (final Widget item in tabs) {
+      if (item is PreferredSizeWidget) {
+        final double itemHeight = item.preferredSize.height;
+        maxHeight = math.max(itemHeight, maxHeight);
+      }
+    }
+    return Size.fromHeight(maxHeight + indicatorWeight);
+  }
+
+  /// Returns whether the [VerticalTabBar] contains a tab with both text and icon.
+  ///
+  /// [VerticalTabBar] uses this to give uniform padding to all tabs in cases where
+  /// there are some tabs with both text and icon and some which contain only
+  /// text or icon.
+  bool get tabHasTextAndIcon {
+    for (final Widget item in tabs) {
+      if (item is PreferredSizeWidget) {
+        if (item.preferredSize.height == _kTextAndIconTabHeight) return true;
+      }
+    }
+    return false;
+  }
+
+  @override
+  State<VerticalTabBar> createState() => _VerticalTabBarState();
+}
+
+class _VerticalTabBarState extends State<VerticalTabBar> {
+  ScrollController? _scrollController;
+  TabController? _controller;
+  _IndicatorPainter? _indicatorPainter;
+  int? _currentIndex;
+  late double _tabStripWidth;
+  late List<GlobalKey> _tabKeys;
+
+  @override
+  void initState() {
+    super.initState();
+    // If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find
+    // the width of tab widget i. See _IndicatorPainter.indicatorRect().
+    _tabKeys = widget.tabs.map((Widget tab) => GlobalKey()).toList();
+  }
+
+  Decoration get _indicator {
+    if (widget.indicator != null) return widget.indicator!;
+    final TabBarTheme tabBarTheme = TabBarTheme.of(context);
+    if (tabBarTheme.indicator != null) return tabBarTheme.indicator!;
+
+    Color color = widget.indicatorColor ?? Theme.of(context).indicatorColor;
+    // ThemeData tries to avoid this by having indicatorColor avoid being the
+    // primaryColor. However, it's possible that the tab bar is on a
+    // Material that isn't the primaryColor. In that case, if the indicator
+    // color ends up matching the material's color, then this overrides it.
+    // When that happens, automatic transitions of the theme will likely look
+    // ugly as the indicator color suddenly snaps to white at one end, but it's
+    // not clear how to avoid that any further.
+    //
+    // The material's color might be null (if it's a transparency). In that case
+    // there's no good way for us to find out what the color is so we don't.
+    //
+    // TODO(xu-baolin): Remove automatic adjustment to white color indicator
+    // with a better long-term solution.
+    // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917
+    if (widget.automaticIndicatorColorAdjustment &&
+        color.value == Material.of(context)?.color?.value) color = Colors.white;
+
+    return UnderlineTabIndicator(
+      borderSide: BorderSide(
+        width: widget.indicatorWeight,
+        color: color,
+      ),
+    );
+  }
+
+  // If the TabBar is rebuilt with a new tab controller, the caller should
+  // dispose the old one. In that case the old controller's animation will be
+  // null and should not be accessed.
+  bool get _controllerIsValid => _controller?.animation != null;
+
+  void _updateTabController() {
+    final TabController? newController =
+        widget.controller ?? DefaultTabController.of(context);
+    assert(() {
+      if (newController == null) {
+        throw FlutterError(
+          'No TabController for ${widget.runtimeType}.\n'
+          'When creating a ${widget.runtimeType}, you must either provide an explicit '
+          'TabController using the "controller" property, or you must ensure that there '
+          'is a DefaultTabController above the ${widget.runtimeType}.\n'
+          'In this case, there was neither an explicit controller nor a default controller.',
+        );
+      }
+      return true;
+    }());
+
+    if (newController == _controller) return;
+
+    if (_controllerIsValid) {
+      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
+      _controller!.removeListener(_handleTabControllerTick);
+    }
+    _controller = newController;
+    if (_controller != null) {
+      _controller!.animation!.addListener(_handleTabControllerAnimationTick);
+      _controller!.addListener(_handleTabControllerTick);
+      _currentIndex = _controller!.index;
+    }
+  }
+
+  void _initIndicatorPainter() {
+    _indicatorPainter = !_controllerIsValid
+        ? null
+        : _IndicatorPainter(
+            controller: _controller!,
+            indicator: _indicator,
+            indicatorSize:
+                widget.indicatorSize ?? TabBarTheme.of(context).indicatorSize,
+            indicatorPadding: widget.indicatorPadding,
+            tabKeys: _tabKeys,
+            old: _indicatorPainter,
+          );
+  }
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    assert(debugCheckHasMaterial(context));
+    _updateTabController();
+    _initIndicatorPainter();
+  }
+
+  @override
+  void didUpdateWidget(VerticalTabBar oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.controller != oldWidget.controller) {
+      _updateTabController();
+      _initIndicatorPainter();
+    } else if (widget.indicatorColor != oldWidget.indicatorColor ||
+        widget.indicatorWeight != oldWidget.indicatorWeight ||
+        widget.indicatorSize != oldWidget.indicatorSize ||
+        widget.indicator != oldWidget.indicator) {
+      _initIndicatorPainter();
+    }
+
+    if (widget.tabs.length > oldWidget.tabs.length) {
+      final int delta = widget.tabs.length - oldWidget.tabs.length;
+      _tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey()));
+    } else if (widget.tabs.length < oldWidget.tabs.length) {
+      _tabKeys.removeRange(widget.tabs.length, oldWidget.tabs.length);
+    }
+  }
+
+  @override
+  void dispose() {
+    _indicatorPainter!.dispose();
+    if (_controllerIsValid) {
+      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
+      _controller!.removeListener(_handleTabControllerTick);
+    }
+    _controller = null;
+    // We don't own the _controller Animation, so it's not disposed here.
+    super.dispose();
+  }
+
+  int get maxTabIndex => _indicatorPainter!.maxTabIndex;
+
+  double _tabScrollOffset(
+      int index, double viewportWidth, double minExtent, double maxExtent) {
+    if (!widget.isScrollable) return 0.0;
+    double tabCenter = _indicatorPainter!.centerOf(index);
+    switch (Directionality.of(context)) {
+      case TextDirection.rtl:
+        tabCenter = _tabStripWidth - tabCenter;
+        break;
+      case TextDirection.ltr:
+        break;
+    }
+    return (tabCenter - viewportWidth / 2.0).clamp(minExtent, maxExtent);
+  }
+
+  double _tabCenteredScrollOffset(int index) {
+    final ScrollPosition position = _scrollController!.position;
+    return _tabScrollOffset(index, position.viewportDimension,
+        position.minScrollExtent, position.maxScrollExtent);
+  }
+
+  double _initialScrollOffset(
+      double viewportWidth, double minExtent, double maxExtent) {
+    return _tabScrollOffset(
+        _currentIndex!, viewportWidth, minExtent, maxExtent);
+  }
+
+  void _scrollToCurrentIndex() {
+    final double offset = _tabCenteredScrollOffset(_currentIndex!);
+    _scrollController!
+        .animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease);
+  }
+
+  void _scrollToControllerValue() {
+    final double? leadingPosition = _currentIndex! > 0
+        ? _tabCenteredScrollOffset(_currentIndex! - 1)
+        : null;
+    final double middlePosition = _tabCenteredScrollOffset(_currentIndex!);
+    final double? trailingPosition = _currentIndex! < maxTabIndex
+        ? _tabCenteredScrollOffset(_currentIndex! + 1)
+        : null;
+
+    final double index = _controller!.index.toDouble();
+    final double value = _controller!.animation!.value;
+    final double offset;
+    if (value == index - 1.0)
+      offset = leadingPosition ?? middlePosition;
+    else if (value == index + 1.0)
+      offset = trailingPosition ?? middlePosition;
+    else if (value == index)
+      offset = middlePosition;
+    else if (value < index)
+      offset = leadingPosition == null
+          ? middlePosition
+          : lerpDouble(middlePosition, leadingPosition, index - value)!;
+    else
+      offset = trailingPosition == null
+          ? middlePosition
+          : lerpDouble(middlePosition, trailingPosition, value - index)!;
+
+    _scrollController!.jumpTo(offset);
+  }
+
+  void _handleTabControllerAnimationTick() {
+    assert(mounted);
+    if (!_controller!.indexIsChanging && widget.isScrollable) {
+      // Sync the TabBar's scroll position with the TabBarView's PageView.
+      _currentIndex = _controller!.index;
+      _scrollToControllerValue();
+    }
+  }
+
+  void _handleTabControllerTick() {
+    if (_controller!.index != _currentIndex) {
+      _currentIndex = _controller!.index;
+      if (widget.isScrollable) _scrollToCurrentIndex();
+    }
+    setState(() {
+      // Rebuild the tabs after a (potentially animated) index change
+      // has completed.
+    });
+  }
+
+  // Called each time layout completes.
+  void _saveTabOffsets(
+      List<double> tabOffsets, TextDirection textDirection, double width) {
+    _tabStripWidth = width;
+    _indicatorPainter?.saveTabOffsets(tabOffsets, textDirection);
+  }
+
+  void _handleTap(int index) {
+    assert(index >= 0 && index < widget.tabs.length);
+    _controller!.animateTo(index);
+    widget.onTap?.call(index);
+  }
+
+  Widget _buildStyledTab(
+      Widget child, bool selected, Animation<double> animation) {
+    return _TabStyle(
+      animation: animation,
+      selected: selected,
+      labelColor: widget.labelColor,
+      unselectedLabelColor: widget.unselectedLabelColor,
+      labelStyle: widget.labelStyle,
+      unselectedLabelStyle: widget.unselectedLabelStyle,
+      child: child,
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    assert(debugCheckHasMaterialLocalizations(context));
+    assert(() {
+      if (_controller!.length != widget.tabs.length) {
+        throw FlutterError(
+          "Controller's length property (${_controller!.length}) does not match the "
+          "number of tabs (${widget.tabs.length}) present in TabBar's tabs property.",
+        );
+      }
+      return true;
+    }());
+    final MaterialLocalizations localizations =
+        MaterialLocalizations.of(context);
+    if (_controller!.length == 0) {
+      return Container(
+        height: _kTabHeight + widget.indicatorWeight,
+      );
+    }
+
+    final TabBarTheme tabBarTheme = TabBarTheme.of(context);
+
+    final List<Widget> wrappedTabs =
+        List<Widget>.generate(widget.tabs.length, (int index) {
+      const double verticalAdjustment =
+          (_kTextAndIconTabHeight - _kTabHeight) / 2.0;
+      EdgeInsetsGeometry? adjustedPadding;
+
+      if (widget.tabs[index] is PreferredSizeWidget) {
+        final PreferredSizeWidget tab =
+            widget.tabs[index] as PreferredSizeWidget;
+        if (widget.tabHasTextAndIcon &&
+            tab.preferredSize.height == _kTabHeight) {
+          if (widget.labelPadding != null || tabBarTheme.labelPadding != null) {
+            adjustedPadding = (widget.labelPadding ?? tabBarTheme.labelPadding!)
+                .add(const EdgeInsets.symmetric(vertical: verticalAdjustment));
+          } else {
+            adjustedPadding = const EdgeInsets.symmetric(
+                vertical: verticalAdjustment, horizontal: 16.0);
+          }
+        }
+      }
+
+      return Center(
+        heightFactor: 1.0,
+        child: Padding(
+          padding: adjustedPadding ??
+              widget.labelPadding ??
+              tabBarTheme.labelPadding ??
+              kTabLabelPadding,
+          child: KeyedSubtree(
+            key: _tabKeys[index],
+            child: widget.tabs[index],
+          ),
+        ),
+      );
+    });
+
+    // If the controller was provided by DefaultTabController and we're part
+    // of a Hero (typically the AppBar), then we will not be able to find the
+    // controller during a Hero transition. See https://github.com/flutter/flutter/issues/213.
+    if (_controller != null) {
+      final int previousIndex = _controller!.previousIndex;
+
+      if (_controller!.indexIsChanging) {
+        // The user tapped on a tab, the tab controller's animation is running.
+        assert(_currentIndex != previousIndex);
+        final Animation<double> animation = _ChangeAnimation(_controller!);
+        wrappedTabs[_currentIndex!] =
+            _buildStyledTab(wrappedTabs[_currentIndex!], true, animation);
+        wrappedTabs[previousIndex] =
+            _buildStyledTab(wrappedTabs[previousIndex], false, animation);
+      } else {
+        // The user is dragging the TabBarView's PageView left or right.
+        final int tabIndex = _currentIndex!;
+        final Animation<double> centerAnimation =
+            _DragAnimation(_controller!, tabIndex);
+        wrappedTabs[tabIndex] =
+            _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation);
+        if (_currentIndex! > 0) {
+          final int tabIndex = _currentIndex! - 1;
+          final Animation<double> previousAnimation =
+              ReverseAnimation(_DragAnimation(_controller!, tabIndex));
+          wrappedTabs[tabIndex] =
+              _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation);
+        }
+        if (_currentIndex! < widget.tabs.length - 1) {
+          final int tabIndex = _currentIndex! + 1;
+          final Animation<double> nextAnimation =
+              ReverseAnimation(_DragAnimation(_controller!, tabIndex));
+          wrappedTabs[tabIndex] =
+              _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation);
+        }
+      }
+    }
+
+    // Add the tap handler to each tab. If the tab bar is not scrollable,
+    // then give all of the tabs equal flexibility so that they each occupy
+    // the same share of the tab bar's overall width.
+    final int tabCount = widget.tabs.length;
+    for (int index = 0; index < tabCount; index += 1) {
+      wrappedTabs[index] = GestureDetector(
+        onTap: () {
+          _handleTap(index);
+        },
+        child: _TabItemWrapper(
+          child: Padding(
+            padding: EdgeInsets.only(bottom: widget.indicatorWeight),
+            child: Stack(
+              children: <Widget>[
+                wrappedTabs[index],
+                Semantics(
+                  selected: index == _currentIndex,
+                  label: localizations.tabLabel(
+                      tabIndex: index + 1, tabCount: tabCount),
+                ),
+              ],
+            ),
+          ),
+        ),
+      );
+      if (!widget.isScrollable) {
+        wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
+      }
+    }
+
+    Widget tabBar = CustomPaint(
+      painter: _indicatorPainter,
+      child: _TabStyle(
+        animation: kAlwaysDismissedAnimation,
+        selected: false,
+        labelColor: widget.labelColor,
+        unselectedLabelColor: widget.unselectedLabelColor,
+        labelStyle: widget.labelStyle,
+        unselectedLabelStyle: widget.unselectedLabelStyle,
+        child: _TabLabelBar(
+          onPerformLayout: _saveTabOffsets,
+          children: wrappedTabs,
+        ),
+      ),
+    );
+
+    if (widget.isScrollable) {
+      _scrollController ??= _TabBarScrollController(this);
+      tabBar = SingleChildScrollView(
+        dragStartBehavior: widget.dragStartBehavior,
+        reverse: true,
+        scrollDirection: Axis.horizontal,
+        controller: _scrollController,
+        padding: widget.padding,
+        physics: widget.physics,
+        child: tabBar,
+      );
+    } else if (widget.padding != null) {
+      tabBar = Padding(
+        padding: widget.padding!,
+        child: tabBar,
+      );
+    }
+
+    return _TabWrapper(child: tabBar);
+  }
+}

+ 1 - 0
lib/widget.dart

@@ -1 +1,2 @@
 export 'src/widget/radar_chart.dart';
+export 'src/widget/vertical_tab_bar.dart';