|
@@ -0,0 +1,1392 @@
|
|
|
+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}) {
|
|
|
+ if (child == null) return false;
|
|
|
+
|
|
|
+ return result.addWithPaintTransform(
|
|
|
+ transform: _getTransform(),
|
|
|
+ position: position,
|
|
|
+ hitTest: (BoxHitTestResult result, Offset? position) {
|
|
|
+ return child!.hitTest(result, position: position!);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 _ClipRectTab extends SingleChildRenderObjectWidget {
|
|
|
+ const _ClipRectTab({
|
|
|
+ Key? key,
|
|
|
+ required Widget child,
|
|
|
+ }) : super(key: key, child: child);
|
|
|
+
|
|
|
+ @override
|
|
|
+ RenderObject createRenderObject(BuildContext context) {
|
|
|
+ return _RenderClipRectTab();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class _RenderClipRectTab extends RenderBox
|
|
|
+ with RenderObjectWithChildMixin<_RenderTabWrapper> {
|
|
|
+ _RenderClipRectTab();
|
|
|
+
|
|
|
+ @override
|
|
|
+ _RenderTabWrapper? get child => super.child as _RenderTabWrapper;
|
|
|
+
|
|
|
+ @override
|
|
|
+ void performLayout() {
|
|
|
+ child!.layout(constraints, parentUsesSize: true);
|
|
|
+ size = child!.size;
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
|
|
+ return child!.hitTest(result, position: position);
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void paint(PaintingContext context, Offset offset) {
|
|
|
+ super.paint(context, offset);
|
|
|
+ context.pushClipRect(
|
|
|
+ needsCompositing,
|
|
|
+ offset,
|
|
|
+ Offset.zero & Size(size.width + 40, size.height),
|
|
|
+ child!.paint,
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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.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;
|
|
|
+
|
|
|
+ /// 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(_tabKeys.length - _currentIndex! - 1);
|
|
|
+ _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(
|
|
|
+ clipBehavior: Clip.none,
|
|
|
+ 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,
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|