mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-04-20 11:08:03 +08:00
2399 lines
82 KiB
Dart
2399 lines
82 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' show SemanticsRole, lerpDouble;
|
|
|
|
import 'package:PiliPlus/pages/main/controller.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:get/get_core/src/get_main.dart';
|
|
import 'package:get/get_instance/src/extension_instance.dart';
|
|
|
|
const double _kTabWidth = 51.0;
|
|
const double _kTextAndIconTabWidth = 72.0;
|
|
const EdgeInsets _kTabLabelPadding = EdgeInsets.symmetric(
|
|
vertical: 7.0,
|
|
horizontal: 5.0,
|
|
);
|
|
|
|
/// A Material Design [VerticalTabBar] tab.
|
|
///
|
|
/// If both [icon] and [text] are provided, the text is displayed below
|
|
/// the icon.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [VerticalTabBar], which displays a row of tabs.
|
|
/// * [TabBarView], which displays a widget for the currently selected tab.
|
|
/// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView].
|
|
/// * <https://material.io/design/components/tabs.html>
|
|
class VerticalTab extends StatelessWidget {
|
|
/// Creates a Material Design [VerticalTabBar] tab.
|
|
///
|
|
/// At least one of [text], [icon], and [child] must be non-null. The [text]
|
|
/// and [child] arguments must not be used at the same time. The
|
|
/// [iconMargin] is only useful when [icon] and either one of [text] or
|
|
/// [child] is non-null.
|
|
const VerticalTab({
|
|
super.key,
|
|
this.text,
|
|
this.icon,
|
|
this.iconMargin,
|
|
this.width,
|
|
this.child,
|
|
}) : assert(text != null || child != null || icon != null),
|
|
assert(text == null || child == null);
|
|
|
|
/// The text to display as the tab's label.
|
|
///
|
|
/// Must not be used in combination with [child].
|
|
final String? text;
|
|
|
|
/// The widget to be used as the tab's label.
|
|
///
|
|
/// Usually a [Text] widget, possibly wrapped in a [Semantics] widget.
|
|
///
|
|
/// Must not be used in combination with [text].
|
|
final Widget? child;
|
|
|
|
/// An icon to display as the tab's label.
|
|
final Widget? icon;
|
|
|
|
/// The margin added around the tab's icon.
|
|
///
|
|
/// Only useful when used in combination with [icon], and either one of
|
|
/// [text] or [child] is non-null.
|
|
///
|
|
/// Defaults to 2 pixels of bottom margin. If [ThemeData.useMaterial3] is false,
|
|
/// then defaults to 10 pixels of bottom margin.
|
|
final EdgeInsetsGeometry? iconMargin;
|
|
|
|
/// The height of the [VerticalTab].
|
|
///
|
|
/// If null, the height will be calculated based on the content of the [VerticalTab]. When `icon` is not
|
|
/// null along with `child` or `text`, the default height is 72.0 pixels. Without an `icon`, the
|
|
/// height is 46.0 pixels.
|
|
///
|
|
/// {@tool snippet}
|
|
///
|
|
/// The provided tab height cannot be lower than the default height. Use
|
|
/// [PreferredSize] widget to adjust the overall [VerticalTabBar] height and match
|
|
/// the provided tab [height]:
|
|
///
|
|
/// ```dart
|
|
/// bottom: const PreferredSize(
|
|
/// preferredSize: Size.fromHeight(20.0),
|
|
/// child: TabBar(
|
|
/// tabs: <Widget>[
|
|
/// Tab(
|
|
/// text: 'Tab 1',
|
|
/// height: 20.0,
|
|
/// ),
|
|
/// Tab(
|
|
/// text: 'Tab 2',
|
|
/// height: 20.0,
|
|
/// ),
|
|
/// ],
|
|
/// ),
|
|
/// ),
|
|
/// ```
|
|
/// {@end-tool}
|
|
final double? width;
|
|
|
|
Widget _buildLabelText() {
|
|
return child ?? Text(text!, style: const TextStyle(fontSize: 15));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterial(context));
|
|
|
|
final double calculatedWidth;
|
|
final Widget label;
|
|
if (icon == null) {
|
|
calculatedWidth = _kTabWidth;
|
|
label = _buildLabelText();
|
|
} else if (text == null && child == null) {
|
|
calculatedWidth = _kTabWidth;
|
|
label = icon!;
|
|
} else {
|
|
calculatedWidth = _kTextAndIconTabWidth;
|
|
final EdgeInsetsGeometry effectiveIconMargin =
|
|
iconMargin ??
|
|
(Theme.of(context).useMaterial3
|
|
? _TabsPrimaryDefaultsM3.iconMargin
|
|
: _TabsDefaultsM2.iconMargin);
|
|
// dom
|
|
label = Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
Padding(padding: effectiveIconMargin, child: icon),
|
|
Flexible(child: _buildLabelText()),
|
|
],
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
width: width ?? calculatedWidth, // dom
|
|
child: Center(heightFactor: 1.0, child: label),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(StringProperty('text', text, defaultValue: null));
|
|
}
|
|
}
|
|
|
|
class _TabStyle extends AnimatedWidget {
|
|
const _TabStyle({
|
|
required Animation<double> animation,
|
|
required this.isSelected,
|
|
required this.isPrimary,
|
|
required this.labelColor,
|
|
required this.unselectedLabelColor,
|
|
required this.labelStyle,
|
|
required this.unselectedLabelStyle,
|
|
required this.defaults,
|
|
required this.child,
|
|
}) : super(listenable: animation);
|
|
|
|
final TextStyle? labelStyle;
|
|
final TextStyle? unselectedLabelStyle;
|
|
final bool isSelected;
|
|
final bool isPrimary;
|
|
final Color? labelColor;
|
|
final Color? unselectedLabelColor;
|
|
final TabBarThemeData defaults;
|
|
final Widget child;
|
|
|
|
WidgetStateColor _resolveWithLabelColor(
|
|
BuildContext context, {
|
|
IconThemeData? iconTheme,
|
|
}) {
|
|
final ThemeData themeData = Theme.of(context);
|
|
final TabBarThemeData tabBarTheme = TabBarTheme.of(context);
|
|
final Animation<double> animation = listenable as Animation<double>;
|
|
|
|
// labelStyle.color (and tabBarTheme.labelStyle.color) is not considered
|
|
// as it'll be a breaking change without a possible migration plan. for
|
|
// details: https://github.com/flutter/flutter/pull/109541#issuecomment-1294241417
|
|
Color selectedColor =
|
|
labelColor ??
|
|
tabBarTheme.labelColor ??
|
|
labelStyle?.color ??
|
|
tabBarTheme.labelStyle?.color ??
|
|
defaults.labelColor!;
|
|
|
|
final Color unselectedColor;
|
|
|
|
if (selectedColor is WidgetStateColor) {
|
|
unselectedColor = selectedColor.resolve(const <WidgetState>{});
|
|
selectedColor = selectedColor.resolve(const <WidgetState>{
|
|
WidgetState.selected,
|
|
});
|
|
} else {
|
|
// unselectedLabelColor and tabBarTheme.unselectedLabelColor are ignored
|
|
// when labelColor is a WidgetStateColor.
|
|
unselectedColor =
|
|
unselectedLabelColor ??
|
|
tabBarTheme.unselectedLabelColor ??
|
|
unselectedLabelStyle?.color ??
|
|
tabBarTheme.unselectedLabelStyle?.color ??
|
|
iconTheme?.color ??
|
|
(themeData.useMaterial3
|
|
? defaults.unselectedLabelColor!
|
|
: selectedColor.withAlpha(0xB2)); // 70% alpha
|
|
}
|
|
|
|
return WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
return Color.lerp(selectedColor, unselectedColor, animation.value)!;
|
|
}
|
|
return Color.lerp(unselectedColor, selectedColor, animation.value)!;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final TabBarThemeData tabBarTheme = TabBarTheme.of(context);
|
|
final Animation<double> animation = listenable as Animation<double>;
|
|
|
|
final Set<WidgetState> states = isSelected
|
|
? const <WidgetState>{WidgetState.selected}
|
|
: const <WidgetState>{};
|
|
|
|
// 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 selectedStyle = defaults.labelStyle!
|
|
.merge(labelStyle ?? tabBarTheme.labelStyle)
|
|
.copyWith(inherit: true);
|
|
final TextStyle unselectedStyle = defaults.unselectedLabelStyle!
|
|
.merge(
|
|
unselectedLabelStyle ??
|
|
tabBarTheme.unselectedLabelStyle ??
|
|
labelStyle,
|
|
)
|
|
.copyWith(inherit: true);
|
|
final TextStyle textStyle = isSelected
|
|
? TextStyle.lerp(selectedStyle, unselectedStyle, animation.value)!
|
|
: TextStyle.lerp(unselectedStyle, selectedStyle, animation.value)!;
|
|
final Color defaultIconColor = switch (theme.colorScheme.brightness) {
|
|
Brightness.light => kDefaultIconDarkColor,
|
|
Brightness.dark => kDefaultIconLightColor,
|
|
};
|
|
final IconThemeData? customIconTheme = switch (IconTheme.of(context)) {
|
|
final IconThemeData iconTheme when iconTheme.color != defaultIconColor =>
|
|
iconTheme,
|
|
_ => null,
|
|
};
|
|
final Color iconColor = _resolveWithLabelColor(
|
|
context,
|
|
iconTheme: customIconTheme,
|
|
).resolve(states);
|
|
final Color labelColor = _resolveWithLabelColor(context).resolve(states);
|
|
|
|
return DefaultTextStyle(
|
|
style: textStyle.copyWith(color: labelColor),
|
|
child: IconTheme.merge(
|
|
data: IconThemeData(
|
|
size: customIconTheme?.size ?? 24.0,
|
|
color: iconColor,
|
|
),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef _LayoutCallback =
|
|
void Function(
|
|
List<double> yOffsets,
|
|
TextDirection textDirection,
|
|
double width,
|
|
);
|
|
|
|
class _TabLabelBarRenderer extends RenderFlex {
|
|
_TabLabelBarRenderer({
|
|
required super.direction,
|
|
required super.mainAxisSize,
|
|
required super.mainAxisAlignment,
|
|
required super.crossAxisAlignment,
|
|
required TextDirection super.textDirection,
|
|
required super.verticalDirection,
|
|
required this.onPerformLayout,
|
|
});
|
|
|
|
_LayoutCallback onPerformLayout;
|
|
|
|
@override
|
|
void performLayout() {
|
|
super.performLayout();
|
|
// yOffsets 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> yOffsets = <double>[];
|
|
while (child != null) {
|
|
final FlexParentData childParentData =
|
|
child.parentData! as FlexParentData;
|
|
yOffsets.add(childParentData.offset.dy); // dom
|
|
assert(child.parentData == childParentData);
|
|
child = childParentData.nextSibling;
|
|
}
|
|
yOffsets.add(size.height);
|
|
onPerformLayout(yOffsets, .ltr, size.height);
|
|
}
|
|
}
|
|
|
|
// This class and its renderer class only exist to report the widths of the tabs
|
|
// upon layout. The tab widths are only used at paint time (see _IndicatorPainter)
|
|
// or in response to input.
|
|
class _TabLabelBar extends Flex {
|
|
const _TabLabelBar({
|
|
super.children,
|
|
required this.onPerformLayout,
|
|
required super.mainAxisSize,
|
|
}) : super(
|
|
direction: Axis.vertical, // dom
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start, // dom
|
|
verticalDirection: VerticalDirection.down,
|
|
);
|
|
|
|
final _LayoutCallback onPerformLayout;
|
|
|
|
@override
|
|
RenderFlex createRenderObject(BuildContext context) {
|
|
return _TabLabelBarRenderer(
|
|
direction: direction,
|
|
mainAxisAlignment: mainAxisAlignment,
|
|
mainAxisSize: mainAxisSize,
|
|
crossAxisAlignment: crossAxisAlignment,
|
|
textDirection: .ltr, // 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 clampDouble((currentIndex - controllerValue).abs(), 0.0, 1.0);
|
|
}
|
|
|
|
// The TabController animation's value is changing from previousIndex to currentIndex.
|
|
return (controllerValue - currentIndex).abs() /
|
|
(currentIndex - previousIndex).abs();
|
|
}
|
|
|
|
class _DividerPainter extends CustomPainter {
|
|
_DividerPainter({required this.dividerColor, required this.dividerWidth});
|
|
|
|
final Color dividerColor;
|
|
final double dividerWidth;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (dividerWidth <= 0.0) {
|
|
return;
|
|
}
|
|
|
|
final Paint paint = Paint()
|
|
..color = dividerColor
|
|
..strokeWidth = dividerWidth;
|
|
|
|
// dom
|
|
final dx = size.width - (paint.strokeWidth / 2);
|
|
canvas.drawLine(Offset(dx, 0), Offset(dx, size.height), paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_DividerPainter oldDelegate) {
|
|
return oldDelegate.dividerColor != dividerColor ||
|
|
oldDelegate.dividerWidth != dividerWidth;
|
|
}
|
|
}
|
|
|
|
class _IndicatorPainter extends CustomPainter {
|
|
_IndicatorPainter({
|
|
required this.controller,
|
|
required this.indicator,
|
|
required this.indicatorSize,
|
|
required this.tabKeys,
|
|
required _IndicatorPainter? old,
|
|
required this.indicatorPadding,
|
|
required this.labelPadding,
|
|
this.dividerColor,
|
|
this.dividerWidth,
|
|
required this.showDivider,
|
|
this.devicePixelRatio,
|
|
required this.indicatorAnimation,
|
|
required this.textDirection,
|
|
}) : super(repaint: controller.animation) {
|
|
assert(debugMaybeDispatchCreated('material', '_IndicatorPainter', this));
|
|
if (old != null) {
|
|
saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
|
|
}
|
|
}
|
|
|
|
final TabController controller;
|
|
final Decoration indicator;
|
|
final TabBarIndicatorSize indicatorSize;
|
|
final EdgeInsetsGeometry indicatorPadding;
|
|
final List<GlobalKey> tabKeys;
|
|
final EdgeInsetsGeometry labelPadding;
|
|
final Color? dividerColor;
|
|
final double? dividerWidth;
|
|
final bool showDivider;
|
|
final double? devicePixelRatio;
|
|
final TabIndicatorAnimation indicatorAnimation;
|
|
final TextDirection textDirection;
|
|
|
|
// _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() {
|
|
assert(debugMaybeDispatchDisposed(this));
|
|
_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;
|
|
(tabLeft, tabRight) = switch (_currentTextDirection!) {
|
|
TextDirection.rtl => (
|
|
_currentTabOffsets![tabIndex + 1],
|
|
_currentTabOffsets![tabIndex],
|
|
),
|
|
TextDirection.ltr => (
|
|
_currentTabOffsets![tabIndex],
|
|
_currentTabOffsets![tabIndex + 1],
|
|
),
|
|
};
|
|
|
|
if (indicatorSize == TabBarIndicatorSize.label) {
|
|
final double tabWidth =
|
|
tabKeys[tabIndex].currentContext!.size!.height; // dom
|
|
final EdgeInsets insets = labelPadding.resolve(_currentTextDirection);
|
|
final double delta =
|
|
((tabRight - tabLeft) - (tabWidth + insets.vertical)) / 2.0; // dom
|
|
tabLeft += delta + insets.top; // dom
|
|
tabRight = tabLeft + tabWidth;
|
|
}
|
|
|
|
final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection);
|
|
// dom
|
|
final Rect rect = Rect.fromLTWH(
|
|
0,
|
|
tabLeft,
|
|
tabBarSize.width,
|
|
tabRight - tabLeft,
|
|
);
|
|
|
|
if (!(rect.size >= insets.collapsedSize)) {
|
|
throw FlutterError(
|
|
'indicatorPadding insets should be less than Tab Size\n'
|
|
'Rect Size : ${rect.size}, Insets: $insets',
|
|
);
|
|
}
|
|
return insets.deflateRect(rect);
|
|
}
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
_needsPaint = false;
|
|
_painter ??= indicator.createBoxPainter(markNeedsPaint);
|
|
|
|
final double value = controller.animation!.value;
|
|
|
|
_currentRect = switch (indicatorAnimation) {
|
|
TabIndicatorAnimation.linear => _applyLinearEffect(
|
|
size: size,
|
|
value: value,
|
|
),
|
|
TabIndicatorAnimation.elastic => _applyElasticEffect(
|
|
size: size,
|
|
value: value,
|
|
),
|
|
};
|
|
|
|
assert(_currentRect != null);
|
|
|
|
final ImageConfiguration configuration = ImageConfiguration(
|
|
size: _currentRect!.size,
|
|
textDirection: _currentTextDirection,
|
|
devicePixelRatio: devicePixelRatio,
|
|
);
|
|
if (showDivider && dividerWidth! > 0) {
|
|
final Paint dividerPaint = Paint()
|
|
..color = dividerColor!
|
|
..strokeWidth = dividerWidth!;
|
|
final dx = size.width - (dividerPaint.strokeWidth / 2);
|
|
final Offset dividerP1 = Offset(dx, 0);
|
|
final Offset dividerP2 = Offset(dx, size.height);
|
|
// dom
|
|
canvas.drawLine(dividerP1, dividerP2, dividerPaint);
|
|
}
|
|
_painter!.paint(canvas, _currentRect!.topLeft, configuration);
|
|
}
|
|
|
|
/// Applies the linear effect to the indicator.
|
|
Rect? _applyLinearEffect({required Size size, required double value}) {
|
|
final double index = controller.index.toDouble();
|
|
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);
|
|
return Rect.lerp(fromRect, toRect, (value - from).abs());
|
|
}
|
|
|
|
// Ease out sine (decelerating).
|
|
double decelerateInterpolation(double fraction) {
|
|
return math.sin((fraction * math.pi) / 2.0);
|
|
}
|
|
|
|
// Ease in sine (accelerating).
|
|
double accelerateInterpolation(double fraction) {
|
|
return 1.0 - math.cos((fraction * math.pi) / 2.0);
|
|
}
|
|
|
|
/// Applies the elastic effect to the indicator.
|
|
// dom
|
|
Rect? _applyElasticEffect({required Size size, required double value}) {
|
|
final double index = controller.index.toDouble();
|
|
double progressLeft = (index - value).abs();
|
|
|
|
final int to = progressLeft == 0.0 || !controller.indexIsChanging
|
|
? switch (textDirection) {
|
|
TextDirection.ltr => value.ceil(),
|
|
TextDirection.rtl => value.floor(),
|
|
}.clamp(0, maxTabIndex)
|
|
: controller.index;
|
|
final int from = progressLeft == 0.0 || !controller.indexIsChanging
|
|
? switch (textDirection) {
|
|
TextDirection.ltr => (to - 1),
|
|
TextDirection.rtl => (to + 1),
|
|
}.clamp(0, maxTabIndex)
|
|
: controller.previousIndex;
|
|
final Rect toRect = indicatorRect(size, to);
|
|
final Rect fromRect = indicatorRect(size, from);
|
|
final Rect rect = Rect.lerp(fromRect, toRect, (value - from).abs())!;
|
|
|
|
// If the tab animation is completed, there is no need to stretch the indicator
|
|
// This only works for the tab change animation via tab index, not when
|
|
// dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations.
|
|
if (controller.animation!.isCompleted) {
|
|
return rect;
|
|
}
|
|
|
|
final double tabChangeProgress;
|
|
|
|
if (controller.indexIsChanging) {
|
|
final int tabsDelta = (controller.index - controller.previousIndex).abs();
|
|
if (tabsDelta != 0) {
|
|
progressLeft /= tabsDelta;
|
|
}
|
|
tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0);
|
|
} else {
|
|
tabChangeProgress = (index - value).abs();
|
|
}
|
|
|
|
// If the animation has finished, there is no need to apply the stretch effect.
|
|
if (tabChangeProgress == 1.0) {
|
|
return rect;
|
|
}
|
|
|
|
final double topFraction;
|
|
final double bottomFraction;
|
|
final bool isMovingRight = switch (textDirection) {
|
|
TextDirection.ltr =>
|
|
controller.indexIsChanging ? index > value : value > index,
|
|
TextDirection.rtl =>
|
|
controller.indexIsChanging ? value > index : index > value,
|
|
};
|
|
if (isMovingRight) {
|
|
topFraction = accelerateInterpolation(tabChangeProgress);
|
|
bottomFraction = decelerateInterpolation(tabChangeProgress);
|
|
} else {
|
|
topFraction = decelerateInterpolation(tabChangeProgress);
|
|
bottomFraction = accelerateInterpolation(tabChangeProgress);
|
|
}
|
|
|
|
final double lerpRectTop;
|
|
final double lerpRectBottom;
|
|
|
|
// The controller.indexIsChanging is true when the Tab is pressed, instead of swipe to change tabs.
|
|
// If the tab is pressed then only lerp between fromRect and toRect.
|
|
if (controller.indexIsChanging) {
|
|
lerpRectTop = lerpDouble(fromRect.top, toRect.top, topFraction)!;
|
|
lerpRectBottom = lerpDouble(
|
|
fromRect.bottom,
|
|
toRect.bottom,
|
|
bottomFraction,
|
|
)!;
|
|
} else {
|
|
// Switch the Rect left and right lerp order based on swipe direction.
|
|
lerpRectTop = switch (isMovingRight) {
|
|
true => lerpDouble(fromRect.top, toRect.top, topFraction)!,
|
|
false => lerpDouble(toRect.top, fromRect.top, topFraction)!,
|
|
};
|
|
lerpRectBottom = switch (isMovingRight) {
|
|
true => lerpDouble(fromRect.bottom, toRect.bottom, bottomFraction)!,
|
|
false => lerpDouble(toRect.bottom, fromRect.bottom, bottomFraction)!,
|
|
};
|
|
}
|
|
|
|
return Rect.fromLTRB(rect.left, lerpRectTop, rect.right, lerpRectBottom);
|
|
}
|
|
|
|
@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 _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 = clampDouble(
|
|
controller.animation!.value,
|
|
0.0,
|
|
controllerMaxValue,
|
|
);
|
|
return clampDouble((controllerValue - index.toDouble()).abs(), 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 super.physics,
|
|
required super.context,
|
|
required super.oldPosition,
|
|
required this.tabBar,
|
|
}) : super(initialPixels: null);
|
|
|
|
final _VerticalTabBarState tabBar;
|
|
|
|
bool _viewportDimensionWasNonZero = false;
|
|
|
|
// The scroll position should be adjusted at least once.
|
|
bool _needsPixelsCorrection = true;
|
|
|
|
@override
|
|
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
|
|
bool result = true;
|
|
if (!_viewportDimensionWasNonZero) {
|
|
_viewportDimensionWasNonZero = viewportDimension != 0.0;
|
|
}
|
|
// 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: 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 start a ballistic scroll activity.
|
|
if (!_viewportDimensionWasNonZero || _needsPixelsCorrection) {
|
|
_needsPixelsCorrection = false;
|
|
correctPixels(
|
|
tabBar._initialScrollOffset(
|
|
viewportDimension,
|
|
minScrollExtent,
|
|
maxScrollExtent,
|
|
),
|
|
);
|
|
result = false;
|
|
}
|
|
return super.applyContentDimensions(minScrollExtent, maxScrollExtent) &&
|
|
result;
|
|
}
|
|
|
|
void markNeedsPixelsCorrection() {
|
|
_needsPixelsCorrection = true;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A Material Design primary tab bar.
|
|
///
|
|
/// Primary tabs are placed at the top of the content pane under a top app bar.
|
|
/// They display the main content destinations.
|
|
///
|
|
/// Typically created as the [AppBar.bottom] part of an [AppBar] and in
|
|
/// conjunction with a [TabBarView].
|
|
///
|
|
/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40}
|
|
///
|
|
/// If a [TabController] is not provided, then a [DefaultTabController] ancestor
|
|
/// must be provided instead. The tab controller's [TabController.length] must
|
|
/// equal the length of the [tabs] list and the length of the
|
|
/// [TabBarView.children] list.
|
|
///
|
|
/// Requires one of its ancestors to be a [Material] widget.
|
|
///
|
|
/// Uses values from [TabBarThemeData] if it is set in the current context.
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample shows the implementation of [VerticalTabBar] and [TabBarView] using a [DefaultTabController].
|
|
/// Each [VerticalTab] corresponds to a child of the [TabBarView] in the order they are written.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.0.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// [VerticalTabBar] can also be implemented by using a [TabController] which provides more options
|
|
/// to control the behavior of the [VerticalTabBar] and [TabBarView]. This can be used instead of
|
|
/// a [DefaultTabController], demonstrated below.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.1.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample showcases nested Material 3 [VerticalTabBar]s. It consists of a primary
|
|
/// [VerticalTabBar] with nested a secondary [VerticalTabBar]. The primary [VerticalTabBar] uses a
|
|
/// [DefaultTabController] while the secondary [VerticalTabBar] uses a [TabController].
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [TabBar.secondary], for a secondary tab bar.
|
|
/// * [TabBarView], which displays page views that correspond to each tab.
|
|
/// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView].
|
|
/// * https://m3.material.io/components/tabs/overview, the Material 3
|
|
/// tab bar specification.
|
|
class VerticalTabBar extends StatefulWidget {
|
|
/// Creates a Material Design primary tab bar.
|
|
///
|
|
/// The length of the [tabs] argument 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.
|
|
///
|
|
/// The [indicatorPadding] parameter defaults to [EdgeInsets.zero].
|
|
///
|
|
/// If [indicator] is not null or provided from [TabBarTheme],
|
|
/// then [indicatorWeight] and [indicatorColor] are ignored.
|
|
const VerticalTabBar({
|
|
super.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.dividerColor,
|
|
this.dividerWidth,
|
|
this.labelColor,
|
|
this.labelStyle,
|
|
this.labelPadding,
|
|
this.unselectedLabelColor,
|
|
this.unselectedLabelStyle,
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
this.overlayColor,
|
|
this.mouseCursor,
|
|
this.enableFeedback,
|
|
this.onTap,
|
|
this.onHover,
|
|
this.onFocusChange,
|
|
this.physics,
|
|
this.splashFactory,
|
|
this.splashBorderRadius,
|
|
this.tabAlignment,
|
|
this.textScaler,
|
|
this.indicatorAnimation,
|
|
}) : _isPrimary = true,
|
|
assert(indicator != null || (indicatorWeight > 0.0));
|
|
|
|
/// Creates a Material Design secondary tab bar.
|
|
///
|
|
/// Secondary tabs are used within a content area to further separate related
|
|
/// content and establish hierarchy.
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample showcases nested Material 3 [VerticalTabBar]s. It consists of a primary
|
|
/// [VerticalTabBar] with nested a secondary [VerticalTabBar]. The primary [VerticalTabBar] uses a
|
|
/// [DefaultTabController] while the secondary [VerticalTabBar] uses a [TabController].
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [VerticalTabBar], for a primary tab bar.
|
|
/// * [TabBarView], which displays page views that correspond to each tab.
|
|
/// * [TabController], which coordinates tab selection between a [VerticalTabBar] and a [TabBarView].
|
|
/// * https://m3.material.io/components/tabs/overview, the Material 3
|
|
/// tab bar specification.
|
|
const VerticalTabBar.secondary({
|
|
super.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.dividerColor,
|
|
this.dividerWidth,
|
|
this.labelColor,
|
|
this.labelStyle,
|
|
this.labelPadding,
|
|
this.unselectedLabelColor,
|
|
this.unselectedLabelStyle,
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
this.overlayColor,
|
|
this.mouseCursor,
|
|
this.enableFeedback,
|
|
this.onTap,
|
|
this.onHover,
|
|
this.onFocusChange,
|
|
this.physics,
|
|
this.splashFactory,
|
|
this.splashBorderRadius,
|
|
this.tabAlignment,
|
|
this.textScaler,
|
|
this.indicatorAnimation,
|
|
}) : _isPrimary = false,
|
|
assert(indicator != null || (indicatorWeight > 0.0));
|
|
|
|
/// Typically a list of two or more [VerticalTab] 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 [VerticalTabBar] was wrapped
|
|
/// 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 [TabBarThemeData],
|
|
/// 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.
|
|
///
|
|
/// If [ThemeData.useMaterial3] is true and [VerticalTabBar] is used to create a
|
|
/// primary tab bar, the default value is 3.0. If the provided value is less
|
|
/// than 3.0, the default value is used.
|
|
///
|
|
/// If [ThemeData.useMaterial3] is true and [TabBar.secondary] is used to
|
|
/// create a secondary tab bar, the default value is 2.0.
|
|
///
|
|
/// If [ThemeData.useMaterial3] is false, the default value is 2.0.
|
|
///
|
|
/// If [indicator] is specified or provided from [TabBarThemeData],
|
|
/// this property is ignored.
|
|
final double indicatorWeight;
|
|
|
|
/// The padding for the indicator.
|
|
///
|
|
/// The default value of this property is [EdgeInsets.zero].
|
|
///
|
|
/// For [isScrollable] tab bars, specifying [kTabLabelPadding] will align
|
|
/// the indicator with the tab's text for [VerticalTab] widgets and all but the
|
|
/// shortest [VerticalTab.text] values.
|
|
final EdgeInsetsGeometry indicatorPadding;
|
|
|
|
/// Defines the appearance of the selected tab indicator.
|
|
///
|
|
/// If [indicator] is specified or provided from [TabBarThemeData],
|
|
/// 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.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [splashBorderRadius], which defines the clipping radius of the splash
|
|
/// and is generally used with [BoxDecoration.borderRadius].
|
|
final Decoration? indicator;
|
|
|
|
/// Whether this tab bar should automatically adjust the [indicatorColor].
|
|
///
|
|
/// The default value of this property is true.
|
|
///
|
|
/// 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 the divider.
|
|
///
|
|
/// If the [dividerColor] is [Colors.transparent], then the divider will not be drawn.
|
|
///
|
|
/// If null and [ThemeData.useMaterial3] is false, [TabBarThemeData.dividerColor]
|
|
/// color is used. If that is null and [ThemeData.useMaterial3] is true,
|
|
/// [ColorScheme.outlineVariant] will be used, otherwise divider will not be drawn.
|
|
final Color? dividerColor;
|
|
|
|
/// The height of the divider.
|
|
///
|
|
/// If the [dividerWidth] is zero or negative, then the divider will not be drawn.
|
|
///
|
|
/// If null and [ThemeData.useMaterial3] is true, [TabBarThemeData.dividerHeight] is used.
|
|
/// If that is also null and [ThemeData.useMaterial3] is true, 1.0 will be used.
|
|
/// Otherwise divider will not be drawn.
|
|
final double? dividerWidth;
|
|
|
|
/// The color of selected tab labels.
|
|
///
|
|
/// If null, then [TabBarThemeData.labelColor] is used. If that is also null and
|
|
/// [ThemeData.useMaterial3] is true, [ColorScheme.primary] will be used,
|
|
/// otherwise the color of the [ThemeData.primaryTextTheme]'s
|
|
/// [TextTheme.bodyLarge] text color is used.
|
|
///
|
|
/// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a
|
|
/// [WidgetStateColor], then the effective tab color will depend on the
|
|
/// [WidgetState.selected] state, i.e. if the [VerticalTab] is selected or not,
|
|
/// ignoring [unselectedLabelColor] even if it's non-null.
|
|
///
|
|
/// When this color or the [TabBarThemeData.labelColor] is specified, it overrides
|
|
/// the [TextStyle.color] specified for the [labelStyle] or the
|
|
/// [TabBarThemeData.labelStyle].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [unselectedLabelColor], for color of unselected tab labels.
|
|
final Color? labelColor;
|
|
|
|
/// The color of unselected tab labels.
|
|
///
|
|
/// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a
|
|
/// [WidgetStateColor], then the unselected tabs are rendered with
|
|
/// that [WidgetStateColor]'s resolved color for unselected state, even if
|
|
/// [unselectedLabelColor] is non-null.
|
|
///
|
|
/// If null, then [TabBarThemeData.unselectedLabelColor] is used. If that is also
|
|
/// null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant]
|
|
/// will be used, otherwise unselected tab labels are rendered with
|
|
/// [labelColor] at 70% opacity.
|
|
///
|
|
/// When this color or the [TabBarThemeData.unselectedLabelColor] is specified, it
|
|
/// overrides the [TextStyle.color] specified for the [unselectedLabelStyle]
|
|
/// or the [TabBarThemeData.unselectedLabelStyle].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [labelColor], for color of selected tab labels.
|
|
final Color? unselectedLabelColor;
|
|
|
|
/// The text style of the selected tab labels.
|
|
///
|
|
/// The color specified in [labelStyle] and [TabBarThemeData.labelStyle] is used
|
|
/// to style the label when [labelColor] or [TabBarThemeData.labelColor] are not
|
|
/// specified.
|
|
///
|
|
/// If [unselectedLabelStyle] is null, then this text style will be used for
|
|
/// both selected and unselected label styles.
|
|
///
|
|
/// If this property is null, then [TabBarThemeData.labelStyle] will be used.
|
|
///
|
|
/// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall]
|
|
/// will be used, otherwise the text style of the [ThemeData.primaryTextTheme]'s
|
|
/// [TextTheme.bodyLarge] definition is used.
|
|
final TextStyle? labelStyle;
|
|
|
|
/// The text style of the unselected tab labels.
|
|
///
|
|
/// The color specified in [unselectedLabelStyle] and [TabBarThemeData.unselectedLabelStyle]
|
|
/// is used to style the label when [unselectedLabelColor] or [TabBarThemeData.unselectedLabelColor]
|
|
/// are not specified.
|
|
///
|
|
/// If this property is null, then [TabBarThemeData.unselectedLabelStyle] will be used.
|
|
///
|
|
/// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall]
|
|
/// will be used, otherwise then the [labelStyle] value is used. If [labelStyle] is null,
|
|
/// the text style of the [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge]
|
|
/// definition is used.
|
|
final TextStyle? unselectedLabelStyle;
|
|
|
|
/// 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;
|
|
|
|
/// Defines the ink response focus, hover, and splash colors.
|
|
///
|
|
/// If non-null, it is resolved against one of [WidgetState.focused],
|
|
/// [WidgetState.hovered], and [WidgetState.pressed].
|
|
///
|
|
/// [WidgetState.pressed] triggers a ripple (an ink splash), per
|
|
/// the current Material Design spec.
|
|
///
|
|
/// If the overlay color is null or resolves to null, then if [ThemeData.useMaterial3] is
|
|
/// false, the default values for [InkResponse.focusColor], [InkResponse.hoverColor], [InkResponse.splashColor],
|
|
/// and [InkResponse.highlightColor] will be used instead. If [ThemeData.useMaterial3]
|
|
/// if true, the default values are:
|
|
/// * selected:
|
|
/// * pressed - ThemeData.colorScheme.primary(0.1)
|
|
/// * hovered - ThemeData.colorScheme.primary(0.08)
|
|
/// * focused - ThemeData.colorScheme.primary(0.1)
|
|
/// * pressed - ThemeData.colorScheme.primary(0.1)
|
|
/// * hovered - ThemeData.colorScheme.onSurface(0.08)
|
|
/// * focused - ThemeData.colorScheme.onSurface(0.1)
|
|
final WidgetStateProperty<Color?>? overlayColor;
|
|
|
|
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
|
|
final DragStartBehavior dragStartBehavior;
|
|
|
|
/// {@template flutter.material.tabs.mouseCursor}
|
|
/// The cursor for a mouse pointer when it enters or is hovering over the
|
|
/// individual tab widgets.
|
|
///
|
|
/// If [mouseCursor] is a [WidgetStateMouseCursor],
|
|
/// [WidgetStateProperty.resolve] is used for the following [WidgetState]s:
|
|
///
|
|
/// * [WidgetState.selected].
|
|
/// {@endtemplate}
|
|
///
|
|
/// If null, then the value of [TabBarThemeData.mouseCursor] is used. If
|
|
/// that is also null, then [WidgetStateMouseCursor.clickable] is 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;
|
|
|
|
/// An optional callback that's called when a [VerticalTab]'s hover state in the
|
|
/// [VerticalTabBar] changes.
|
|
///
|
|
/// Called when a pointer enters or exits the ink response area of the [VerticalTab].
|
|
///
|
|
/// The value passed to the callback is true if a pointer has entered the
|
|
/// [VerticalTab] at `index` and false if a pointer has exited.
|
|
///
|
|
/// When hover is moved from one tab directly to another, this will be called
|
|
/// twice. First to represent hover exiting the initial tab, and then second
|
|
/// for the pointer entering hover over the next tab.
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample shows how to customize a [VerticalTab] in response to hovering over a
|
|
/// [VerticalTabBar].
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.onHover.dart **
|
|
/// {@end-tool}
|
|
final TabValueChanged<bool>? onHover;
|
|
|
|
/// An optional callback that's called when a [VerticalTab]'s focus state in the
|
|
/// [VerticalTabBar] changes.
|
|
///
|
|
/// Called when the node for the [VerticalTab] at `index` gains or loses focus.
|
|
///
|
|
/// The value passed to the callback is true if the node has gained focus for
|
|
/// the [VerticalTab] at `index` and false if focus has been lost.
|
|
///
|
|
/// When focus is moved from one tab directly to another, this will be called
|
|
/// twice. First to represent focus being lost by the initially focused tab,
|
|
/// and then second for the next tab gaining focus.
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample shows how to customize a [VerticalTab] based on focus traversal in
|
|
/// enclosing [VerticalTabBar].
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.onFocusChange.dart **
|
|
/// {@end-tool}
|
|
final TabValueChanged<bool>? onFocusChange;
|
|
|
|
/// 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;
|
|
|
|
/// Creates the tab bar's [InkWell] splash factory, which defines
|
|
/// the appearance of "ink" splashes that occur in response to taps.
|
|
///
|
|
/// Use [NoSplash.splashFactory] to defeat ink splash rendering. For example
|
|
/// to defeat both the splash and the hover/pressed overlay, but not the
|
|
/// keyboard focused overlay:
|
|
///
|
|
/// ```dart
|
|
/// TabBar(
|
|
/// splashFactory: NoSplash.splashFactory,
|
|
/// overlayColor: WidgetStateProperty.resolveWith<Color?>(
|
|
/// (Set<WidgetState> states) {
|
|
/// return states.contains(WidgetState.focused) ? null : Colors.transparent;
|
|
/// },
|
|
/// ),
|
|
/// tabs: const <Widget>[
|
|
/// // ...
|
|
/// ],
|
|
/// )
|
|
/// ```
|
|
final InteractiveInkFeatureFactory? splashFactory;
|
|
|
|
/// Defines the clipping radius of splashes that extend outside the bounds of the tab.
|
|
///
|
|
/// This can be useful to match the [BoxDecoration.borderRadius] provided as [indicator].
|
|
///
|
|
/// ```dart
|
|
/// TabBar(
|
|
/// indicator: BoxDecoration(
|
|
/// borderRadius: BorderRadius.circular(40),
|
|
/// ),
|
|
/// splashBorderRadius: BorderRadius.circular(40),
|
|
/// tabs: const <Widget>[
|
|
/// // ...
|
|
/// ],
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// If this property is null, it is interpreted as [BorderRadius.zero].
|
|
final BorderRadius? splashBorderRadius;
|
|
|
|
/// Specifies the horizontal alignment of the tabs within a [VerticalTabBar].
|
|
///
|
|
/// If [VerticalTabBar.isScrollable] is false, only [TabAlignment.fill] and
|
|
/// [TabAlignment.center] are supported. Otherwise an exception is thrown.
|
|
///
|
|
/// If [VerticalTabBar.isScrollable] is true, only [TabAlignment.start], [TabAlignment.startOffset],
|
|
/// and [TabAlignment.center] are supported. Otherwise an exception is thrown.
|
|
///
|
|
/// If this is null, then the value of [TabBarThemeData.tabAlignment] is used.
|
|
///
|
|
/// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is true,
|
|
/// then [TabAlignment.startOffset] is used if [isScrollable] is true,
|
|
/// otherwise [TabAlignment.fill] is used.
|
|
///
|
|
/// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is false,
|
|
/// then [TabAlignment.center] is used if [isScrollable] is true,
|
|
/// otherwise [TabAlignment.fill] is used.
|
|
final TabAlignment? tabAlignment;
|
|
|
|
/// Specifies the text scaling behavior for the [VerticalTab] label.
|
|
///
|
|
/// If this is null, then the value of [TabBarThemeData.textScaler] is used. If that is
|
|
/// also null, then the text scaling behavior is determined by the [MediaQueryData.textScaler]
|
|
/// from the ambient [MediaQuery], or 1.0 if there is no [MediaQuery] in scope.
|
|
///
|
|
/// See also:
|
|
/// * [TextScaler], which is used to scale text based on the device's text scale factor.
|
|
final TextScaler? textScaler;
|
|
|
|
/// Specifies the animation behavior of the tab indicator.
|
|
///
|
|
/// If this is null, then the value of [TabBarThemeData.indicatorAnimation] is used.
|
|
/// If that is also null, then the tab indicator will animate linearly if the
|
|
/// [indicatorSize] is [TabBarIndicatorSize.tab], otherwise it will animate
|
|
/// with an elastic effect if the [indicatorSize] is [TabBarIndicatorSize.label].
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This sample shows how to customize the animation behavior of the tab indicator
|
|
/// by using the [indicatorAnimation] property.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [TabIndicatorAnimation], which specifies the animation behavior of the tab indicator.
|
|
final TabIndicatorAnimation? indicatorAnimation;
|
|
|
|
/// 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 == _kTextAndIconTabWidth) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Whether this tab bar is a primary tab bar.
|
|
///
|
|
/// Otherwise, it is a secondary tab bar.
|
|
final bool _isPrimary;
|
|
|
|
@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;
|
|
EdgeInsetsGeometry _labelPadding = EdgeInsets.zero;
|
|
bool _debugHasScheduledValidTabsCountCheck = false;
|
|
|
|
@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();
|
|
// _labelPaddings = List<EdgeInsetsGeometry>.filled(
|
|
// widget.tabs.length,
|
|
// EdgeInsets.zero,
|
|
// growable: true,
|
|
// );
|
|
}
|
|
|
|
TabBarThemeData get _defaults {
|
|
if (Theme.of(context).useMaterial3) {
|
|
return widget._isPrimary
|
|
? _TabsPrimaryDefaultsM3(context, widget.isScrollable)
|
|
: _TabsSecondaryDefaultsM3(context, widget.isScrollable);
|
|
} else {
|
|
return _TabsDefaultsM2(context, widget.isScrollable);
|
|
}
|
|
}
|
|
|
|
Decoration _getIndicator(TabBarIndicatorSize indicatorSize) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final TabBarThemeData tabBarTheme = TabBarTheme.of(context);
|
|
|
|
if (widget.indicator != null) {
|
|
return widget.indicator!;
|
|
}
|
|
if (tabBarTheme.indicator != null) {
|
|
return tabBarTheme.indicator!;
|
|
}
|
|
|
|
Color color =
|
|
widget.indicatorColor ??
|
|
tabBarTheme.indicatorColor ??
|
|
_defaults.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.toARGB32() == Material.maybeOf(context)?.color?.toARGB32()) {
|
|
color = Colors.white;
|
|
}
|
|
|
|
final double effectiveIndicatorWeight = theme.useMaterial3
|
|
? math.max(widget.indicatorWeight, switch (widget._isPrimary) {
|
|
true => _TabsPrimaryDefaultsM3.indicatorWeight(indicatorSize),
|
|
false => _TabsSecondaryDefaultsM3.indicatorWeight,
|
|
})
|
|
: widget.indicatorWeight;
|
|
// Only Material 3 primary TabBar with label indicatorSize should be rounded.
|
|
final bool primaryWithLabelIndicator = switch (indicatorSize) {
|
|
TabBarIndicatorSize.label => widget._isPrimary,
|
|
TabBarIndicatorSize.tab => false,
|
|
};
|
|
final BorderRadius? effectiveBorderRadius =
|
|
theme.useMaterial3 && primaryWithLabelIndicator
|
|
// dom
|
|
? BorderRadius.only(
|
|
topRight: Radius.circular(effectiveIndicatorWeight),
|
|
bottomRight: Radius.circular(effectiveIndicatorWeight),
|
|
)
|
|
: null;
|
|
return VerticalUnderlineTabIndicator(
|
|
borderRadius: effectiveBorderRadius,
|
|
borderSide: BorderSide(
|
|
// TODO(tahatesser): Make sure this value matches Material 3 Tabs spec
|
|
// when `preferredSize`and `indicatorWeight` are updated to support Material 3
|
|
// https://m3.material.io/components/tabs/specs#149a189f-9039-4195-99da-15c205d20e30,
|
|
// https://github.com/flutter/flutter/issues/116136
|
|
width: effectiveIndicatorWeight,
|
|
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.maybeOf(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() {
|
|
final ThemeData theme = Theme.of(context);
|
|
final TabBarThemeData tabBarTheme = TabBarTheme.of(context);
|
|
final TabBarIndicatorSize indicatorSize =
|
|
widget.indicatorSize ??
|
|
tabBarTheme.indicatorSize ??
|
|
_defaults.indicatorSize!;
|
|
|
|
final _IndicatorPainter? oldPainter = _indicatorPainter;
|
|
|
|
final TabIndicatorAnimation defaultTabIndicatorAnimation =
|
|
switch (indicatorSize) {
|
|
TabBarIndicatorSize.label => TabIndicatorAnimation.elastic,
|
|
TabBarIndicatorSize.tab => TabIndicatorAnimation.linear,
|
|
};
|
|
|
|
_indicatorPainter = !_controllerIsValid
|
|
? null
|
|
: _IndicatorPainter(
|
|
controller: _controller!,
|
|
indicator: _getIndicator(indicatorSize),
|
|
indicatorSize: indicatorSize,
|
|
indicatorPadding: widget.indicatorPadding,
|
|
tabKeys: _tabKeys,
|
|
// Passing old painter so that the constructor can copy some values from it.
|
|
old: oldPainter,
|
|
labelPadding: _labelPadding,
|
|
dividerColor:
|
|
widget.dividerColor ??
|
|
tabBarTheme.dividerColor ??
|
|
_defaults.dividerColor,
|
|
dividerWidth:
|
|
widget.dividerWidth ??
|
|
tabBarTheme.dividerHeight ??
|
|
_defaults.dividerHeight,
|
|
showDivider: theme.useMaterial3 && !widget.isScrollable,
|
|
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
|
|
indicatorAnimation:
|
|
widget.indicatorAnimation ??
|
|
tabBarTheme.indicatorAnimation ??
|
|
defaultTabIndicatorAnimation,
|
|
textDirection: .ltr,
|
|
);
|
|
|
|
oldPainter?.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_updateTabController();
|
|
_initIndicatorPainter();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(VerticalTabBar oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.controller != oldWidget.controller) {
|
|
_updateTabController();
|
|
_initIndicatorPainter();
|
|
// Adjust scroll position.
|
|
if (_scrollController != null && _scrollController!.hasClients) {
|
|
final ScrollPosition position = _scrollController!.position;
|
|
if (position is _TabBarScrollPosition) {
|
|
position.markNeedsPixelsCorrection();
|
|
}
|
|
}
|
|
} else if (widget.indicatorColor != oldWidget.indicatorColor ||
|
|
widget.indicatorWeight != oldWidget.indicatorWeight ||
|
|
widget.indicatorSize != oldWidget.indicatorSize ||
|
|
widget.indicatorPadding != oldWidget.indicatorPadding ||
|
|
widget.indicator != oldWidget.indicator ||
|
|
widget.dividerColor != oldWidget.dividerColor ||
|
|
widget.dividerWidth != oldWidget.dividerWidth ||
|
|
widget.indicatorAnimation != oldWidget.indicatorAnimation) {
|
|
_initIndicatorPainter();
|
|
}
|
|
|
|
if (widget.tabs.length > _tabKeys.length) {
|
|
final int delta = widget.tabs.length - _tabKeys.length;
|
|
_tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey()));
|
|
// _labelPaddings.addAll(
|
|
// List<EdgeInsetsGeometry>.filled(delta, EdgeInsets.zero),
|
|
// );
|
|
} else if (widget.tabs.length < _tabKeys.length) {
|
|
_tabKeys.removeRange(widget.tabs.length, _tabKeys.length);
|
|
// _labelPaddings.removeRange(widget.tabs.length, _tabKeys.length);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_indicatorPainter!.dispose();
|
|
if (_controllerIsValid) {
|
|
_controller!.animation!.removeListener(_handleTabControllerAnimationTick);
|
|
_controller!.removeListener(_handleTabControllerTick);
|
|
}
|
|
_controller = null;
|
|
_scrollController?.dispose();
|
|
// We don't own the _controller Animation, so it's not disposed here.
|
|
super.dispose();
|
|
}
|
|
|
|
int get maxTabIndex => _indicatorPainter!.maxTabIndex;
|
|
|
|
final _mainCtr = Get.find<MainController>();
|
|
|
|
double _tabScrollOffset(
|
|
int index,
|
|
double viewportWidth,
|
|
double minExtent,
|
|
double maxExtent,
|
|
) {
|
|
if (!widget.isScrollable) {
|
|
return 0.0;
|
|
}
|
|
double tabCenter = _indicatorPainter!.centerOf(index);
|
|
// double paddingStart;
|
|
// switch (Directionality.of(context)) {
|
|
// case TextDirection.rtl:
|
|
// paddingStart = widget.padding?.resolve(TextDirection.rtl).right ?? 0;
|
|
// tabCenter = _tabStripWidth - tabCenter;
|
|
// case TextDirection.ltr:
|
|
// paddingStart = widget.padding?.resolve(TextDirection.ltr).left ?? 0;
|
|
// }
|
|
// dom
|
|
final double paddingTop =
|
|
widget.padding?.resolve(TextDirection.ltr).top ?? 0;
|
|
return clampDouble(
|
|
tabCenter +
|
|
paddingTop -
|
|
viewportWidth / 2.0 +
|
|
(_mainCtr.useBottomNav && (_mainCtr.barOffset?.value ?? 0) == 0
|
|
? 80.0
|
|
: 0.0),
|
|
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 = switch (value - index) {
|
|
-1.0 => leadingPosition ?? middlePosition,
|
|
1.0 => trailingPosition ?? middlePosition,
|
|
0 => middlePosition,
|
|
< 0 =>
|
|
leadingPosition == null
|
|
? middlePosition
|
|
: lerpDouble(middlePosition, leadingPosition, index - value)!,
|
|
_ =>
|
|
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 isSelected,
|
|
Animation<double> animation,
|
|
TabBarThemeData defaults,
|
|
) {
|
|
return _TabStyle(
|
|
animation: animation,
|
|
isSelected: isSelected,
|
|
isPrimary: widget._isPrimary,
|
|
labelColor: widget.labelColor,
|
|
unselectedLabelColor: widget.unselectedLabelColor,
|
|
labelStyle: widget.labelStyle,
|
|
unselectedLabelStyle: widget.unselectedLabelStyle,
|
|
defaults: defaults,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
bool _debugScheduleCheckHasValidTabsCount() {
|
|
if (_debugHasScheduledValidTabsCountCheck) {
|
|
return true;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
|
|
_debugHasScheduledValidTabsCountCheck = false;
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
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;
|
|
}());
|
|
}, debugLabel: 'TabBar.tabsCountCheck');
|
|
_debugHasScheduledValidTabsCountCheck = true;
|
|
return true;
|
|
}
|
|
|
|
bool _debugTabAlignmentIsValid(TabAlignment tabAlignment) {
|
|
assert(() {
|
|
if (widget.isScrollable && tabAlignment == TabAlignment.fill) {
|
|
throw FlutterError(
|
|
'$tabAlignment is only valid for non-scrollable tab bars.',
|
|
);
|
|
}
|
|
if (!widget.isScrollable &&
|
|
(tabAlignment == TabAlignment.start ||
|
|
tabAlignment == TabAlignment.startOffset)) {
|
|
throw FlutterError(
|
|
'$tabAlignment is only valid for scrollable tab bars.',
|
|
);
|
|
}
|
|
return true;
|
|
}());
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
assert(_debugScheduleCheckHasValidTabsCount());
|
|
final ThemeData theme = Theme.of(context);
|
|
final TabBarThemeData tabBarTheme = TabBarTheme.of(context);
|
|
final TabAlignment effectiveTabAlignment =
|
|
widget.tabAlignment ??
|
|
tabBarTheme.tabAlignment ??
|
|
_defaults.tabAlignment!;
|
|
assert(_debugTabAlignmentIsValid(effectiveTabAlignment));
|
|
|
|
// final MaterialLocalizations localizations = MaterialLocalizations.of(
|
|
// context,
|
|
// );
|
|
if (_controller!.length == 0) {
|
|
return LimitedBox(
|
|
maxWidth: 0.0,
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: _kTabWidth + widget.indicatorWeight,
|
|
),
|
|
);
|
|
}
|
|
|
|
final List<Widget> wrappedTabs = List<Widget>.generate(widget.tabs.length, (
|
|
int index,
|
|
) {
|
|
EdgeInsetsGeometry padding =
|
|
widget.labelPadding ?? tabBarTheme.labelPadding ?? _kTabLabelPadding;
|
|
// const double verticalAdjustment =
|
|
// (_kTextAndIconTabWidth - _kTabWidth) / 2.0;
|
|
//
|
|
// final Widget tab = widget.tabs[index];
|
|
// if (tab is PreferredSizeWidget &&
|
|
// tab.preferredSize.height == _kTabWidth &&
|
|
// widget.tabHasTextAndIcon) {
|
|
// padding = padding.add(
|
|
// const EdgeInsets.symmetric(vertical: verticalAdjustment),
|
|
// );
|
|
// }
|
|
_labelPadding = padding;
|
|
|
|
return Center(
|
|
widthFactor: 1.0,
|
|
child: Padding(
|
|
padding: _labelPadding,
|
|
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,
|
|
_defaults,
|
|
);
|
|
wrappedTabs[previousIndex] = _buildStyledTab(
|
|
wrappedTabs[previousIndex],
|
|
false,
|
|
animation,
|
|
_defaults,
|
|
);
|
|
} 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,
|
|
_defaults,
|
|
);
|
|
if (_currentIndex! > 0) {
|
|
final int tabIndex = _currentIndex! - 1;
|
|
final Animation<double> previousAnimation = ReverseAnimation(
|
|
_DragAnimation(_controller!, tabIndex),
|
|
);
|
|
wrappedTabs[tabIndex] = _buildStyledTab(
|
|
wrappedTabs[tabIndex],
|
|
false,
|
|
previousAnimation,
|
|
_defaults,
|
|
);
|
|
}
|
|
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,
|
|
_defaults,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
final Set<WidgetState> selectedState = <WidgetState>{
|
|
if (index == _currentIndex) WidgetState.selected,
|
|
};
|
|
|
|
final MouseCursor effectiveMouseCursor =
|
|
WidgetStateProperty.resolveAs<MouseCursor?>(
|
|
widget.mouseCursor,
|
|
selectedState,
|
|
) ??
|
|
tabBarTheme.mouseCursor?.resolve(selectedState) ??
|
|
WidgetStateMouseCursor.clickable.resolve(selectedState);
|
|
|
|
final WidgetStateProperty<Color?> defaultOverlay =
|
|
WidgetStateProperty.resolveWith<Color?>((
|
|
Set<WidgetState> states,
|
|
) {
|
|
final Set<WidgetState> effectiveStates = selectedState.toSet()
|
|
..addAll(states);
|
|
return _defaults.overlayColor?.resolve(effectiveStates);
|
|
});
|
|
wrappedTabs[index] = InkWell(
|
|
mouseCursor: effectiveMouseCursor,
|
|
onTap: () {
|
|
_handleTap(index);
|
|
},
|
|
onHover: (bool value) {
|
|
widget.onHover?.call(value, index);
|
|
},
|
|
onFocusChange: (bool value) {
|
|
widget.onFocusChange?.call(value, index);
|
|
},
|
|
enableFeedback: widget.enableFeedback ?? true,
|
|
overlayColor:
|
|
widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay,
|
|
splashFactory:
|
|
widget.splashFactory ??
|
|
tabBarTheme.splashFactory ??
|
|
_defaults.splashFactory,
|
|
borderRadius:
|
|
widget.splashBorderRadius ??
|
|
tabBarTheme.splashBorderRadius ??
|
|
_defaults.splashBorderRadius,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(left: widget.indicatorWeight),
|
|
child: wrappedTabs[index],
|
|
),
|
|
);
|
|
wrappedTabs[index] = MergeSemantics(child: wrappedTabs[index]);
|
|
if (!widget.isScrollable && effectiveTabAlignment == TabAlignment.fill) {
|
|
wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
|
|
}
|
|
}
|
|
|
|
Widget tabBar = Semantics(
|
|
role: SemanticsRole.tabBar,
|
|
container: true,
|
|
explicitChildNodes: true,
|
|
child: CustomPaint(
|
|
painter: _indicatorPainter,
|
|
child: _TabStyle(
|
|
animation: kAlwaysDismissedAnimation,
|
|
isSelected: false,
|
|
isPrimary: widget._isPrimary,
|
|
labelColor: widget.labelColor,
|
|
unselectedLabelColor: widget.unselectedLabelColor,
|
|
labelStyle: widget.labelStyle,
|
|
unselectedLabelStyle: widget.unselectedLabelStyle,
|
|
defaults: _defaults,
|
|
child: _TabLabelBar(
|
|
onPerformLayout: _saveTabOffsets,
|
|
mainAxisSize: effectiveTabAlignment == TabAlignment.fill
|
|
? MainAxisSize.max
|
|
: MainAxisSize.min,
|
|
children: wrappedTabs,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (widget.isScrollable) {
|
|
_scrollController ??= _TabBarScrollController(this);
|
|
tabBar = ScrollConfiguration(
|
|
// The scrolling tabs should not show an overscroll indicator.
|
|
behavior: ScrollConfiguration.of(context).copyWith(overscroll: false),
|
|
child: SingleChildScrollView(
|
|
dragStartBehavior: widget.dragStartBehavior,
|
|
scrollDirection: Axis.vertical, // dom
|
|
controller: _scrollController,
|
|
padding: widget.padding,
|
|
physics: widget.physics,
|
|
child: tabBar,
|
|
),
|
|
);
|
|
if (theme.useMaterial3) {
|
|
final AlignmentGeometry effectiveAlignment =
|
|
switch (effectiveTabAlignment) {
|
|
TabAlignment.center => Alignment.center,
|
|
TabAlignment.start ||
|
|
TabAlignment.startOffset ||
|
|
TabAlignment.fill => AlignmentDirectional.topCenter, // dom
|
|
};
|
|
|
|
final Color dividerColor =
|
|
widget.dividerColor ??
|
|
tabBarTheme.dividerColor ??
|
|
_defaults.dividerColor!;
|
|
final double dividerWidth =
|
|
widget.dividerWidth ??
|
|
tabBarTheme.dividerHeight ??
|
|
_defaults.dividerHeight!;
|
|
|
|
tabBar = Align(
|
|
widthFactor: 1.0,
|
|
heightFactor: null, // dom
|
|
// heightFactor: dividerWidth > 0 ? null : 1.0,
|
|
alignment: effectiveAlignment,
|
|
child: tabBar,
|
|
);
|
|
|
|
if (dividerColor != Colors.transparent && dividerWidth > 0) {
|
|
tabBar = CustomPaint(
|
|
painter: _DividerPainter(
|
|
dividerColor: dividerColor,
|
|
dividerWidth: dividerWidth,
|
|
),
|
|
child: tabBar,
|
|
);
|
|
}
|
|
}
|
|
} else if (widget.padding != null) {
|
|
tabBar = Padding(padding: widget.padding!, child: tabBar);
|
|
}
|
|
|
|
return MediaQuery(
|
|
data: MediaQuery.of(
|
|
context,
|
|
).copyWith(textScaler: widget.textScaler ?? tabBarTheme.textScaler),
|
|
child: tabBar,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Hand coded defaults based on Material Design 2.
|
|
class _TabsDefaultsM2 extends TabBarThemeData {
|
|
_TabsDefaultsM2(this.context, this.isScrollable)
|
|
: super(indicatorSize: TabBarIndicatorSize.tab);
|
|
|
|
final BuildContext context;
|
|
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
|
late final bool isDark = Theme.brightnessOf(context) == Brightness.dark;
|
|
late final Color primaryColor = isDark ? Colors.grey[900]! : Colors.blue;
|
|
final bool isScrollable;
|
|
|
|
@override
|
|
Color? get indicatorColor =>
|
|
_colors.secondary == primaryColor ? Colors.white : _colors.secondary;
|
|
|
|
@override
|
|
Color? get labelColor => Theme.of(context).primaryTextTheme.bodyLarge!.color!;
|
|
|
|
@override
|
|
TextStyle? get labelStyle => Theme.of(context).primaryTextTheme.bodyLarge;
|
|
|
|
@override
|
|
TextStyle? get unselectedLabelStyle =>
|
|
Theme.of(context).primaryTextTheme.bodyLarge;
|
|
|
|
@override
|
|
InteractiveInkFeatureFactory? get splashFactory =>
|
|
Theme.of(context).splashFactory;
|
|
|
|
@override
|
|
TabAlignment? get tabAlignment =>
|
|
isScrollable ? TabAlignment.start : TabAlignment.fill;
|
|
|
|
static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 10);
|
|
}
|
|
|
|
// BEGIN GENERATED TOKEN PROPERTIES - Tabs
|
|
|
|
// Do not edit by hand. The code between the "BEGIN GENERATED" and
|
|
// "END GENERATED" comments are generated from data in the Material
|
|
// Design token database by the script:
|
|
// dev/tools/gen_defaults/bin/gen_defaults.dart.
|
|
|
|
// dart format off
|
|
class _TabsPrimaryDefaultsM3 extends TabBarThemeData {
|
|
_TabsPrimaryDefaultsM3(this.context, this.isScrollable)
|
|
: super(indicatorSize: TabBarIndicatorSize.label);
|
|
|
|
final BuildContext context;
|
|
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
|
late final TextTheme _textTheme = Theme.of(context).textTheme;
|
|
final bool isScrollable;
|
|
|
|
// This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.color' token.
|
|
@override
|
|
Color? get dividerColor => _colors.outlineVariant;
|
|
|
|
// This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.height' token.
|
|
@override
|
|
double? get dividerHeight => 1.0;
|
|
|
|
@override
|
|
Color? get indicatorColor => _colors.primary;
|
|
|
|
@override
|
|
Color? get labelColor => _colors.primary;
|
|
|
|
@override
|
|
TextStyle? get labelStyle => _textTheme.titleSmall;
|
|
|
|
@override
|
|
Color? get unselectedLabelColor => _colors.onSurfaceVariant;
|
|
|
|
@override
|
|
TextStyle? get unselectedLabelStyle => _textTheme.titleSmall;
|
|
|
|
@override
|
|
WidgetStateProperty<Color?> get overlayColor {
|
|
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return _colors.primary.withValues(alpha:0.1);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return _colors.primary.withValues(alpha:0.08);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return _colors.primary.withValues(alpha:0.1);
|
|
}
|
|
return null;
|
|
}
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return _colors.primary.withValues(alpha:0.1);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return _colors.onSurface.withValues(alpha:0.08);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return _colors.onSurface.withValues(alpha:0.1);
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
|
|
|
|
@override
|
|
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
|
|
|
|
static double indicatorWeight(TabBarIndicatorSize indicatorSize) {
|
|
return switch (indicatorSize) {
|
|
TabBarIndicatorSize.label => 3.0,
|
|
TabBarIndicatorSize.tab => 2.0,
|
|
};
|
|
}
|
|
|
|
// TODO(davidmartos96): This value doesn't currently exist in
|
|
// https://m3.material.io/components/tabs/specs
|
|
// Update this when the token is available.
|
|
static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 2);
|
|
}
|
|
|
|
class _TabsSecondaryDefaultsM3 extends TabBarThemeData {
|
|
_TabsSecondaryDefaultsM3(this.context, this.isScrollable)
|
|
: super(indicatorSize: TabBarIndicatorSize.tab);
|
|
|
|
final BuildContext context;
|
|
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
|
late final TextTheme _textTheme = Theme.of(context).textTheme;
|
|
final bool isScrollable;
|
|
|
|
// This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.color' token.
|
|
@override
|
|
Color? get dividerColor => _colors.outlineVariant;
|
|
|
|
// This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.height' token.
|
|
@override
|
|
double? get dividerHeight => 1.0;
|
|
|
|
@override
|
|
Color? get indicatorColor => _colors.primary;
|
|
|
|
@override
|
|
Color? get labelColor => _colors.onSurface;
|
|
|
|
@override
|
|
TextStyle? get labelStyle => _textTheme.titleSmall;
|
|
|
|
@override
|
|
Color? get unselectedLabelColor => _colors.onSurfaceVariant;
|
|
|
|
@override
|
|
TextStyle? get unselectedLabelStyle => _textTheme.titleSmall;
|
|
|
|
@override
|
|
WidgetStateProperty<Color?> get overlayColor {
|
|
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return _colors.onSurface.withValues(alpha:0.1);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return _colors.onSurface.withValues(alpha: 0.08);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return _colors.onSurface.withValues(alpha:0.1);
|
|
}
|
|
return null;
|
|
}
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return _colors.onSurface.withValues(alpha:0.1);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return _colors.onSurface.withValues(alpha:0.08);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return _colors.onSurface.withValues(alpha:0.1);
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
|
|
|
|
@override
|
|
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
|
|
|
|
static double indicatorWeight = 2.0;
|
|
}
|
|
// dart format on
|
|
|
|
// END GENERATED TOKEN PROPERTIES - Tabs
|
|
|
|
/// Used with [TabBar.indicator] to draw a horizontal line below the
|
|
/// selected tab.
|
|
///
|
|
/// The selected tab underline is inset from the tab's boundary by [insets].
|
|
/// The [borderSide] defines the line's color and weight.
|
|
///
|
|
/// The [TabBar.indicatorSize] property can be used to define the indicator's
|
|
/// bounds in terms of its (centered) widget with [TabBarIndicatorSize.label],
|
|
/// or the entire tab with [TabBarIndicatorSize.tab].
|
|
class VerticalUnderlineTabIndicator extends Decoration {
|
|
/// Create an underline style selected tab indicator.
|
|
const VerticalUnderlineTabIndicator({
|
|
this.borderRadius,
|
|
this.borderSide = const BorderSide(width: 2.0, color: Colors.white),
|
|
this.insets = EdgeInsets.zero,
|
|
});
|
|
|
|
/// The radius of the indicator's corners.
|
|
///
|
|
/// If this value is non-null, rounded rectangular tab indicator is
|
|
/// drawn, otherwise rectangular tab indicator is drawn.
|
|
final BorderRadius? borderRadius;
|
|
|
|
/// The color and weight of the horizontal line drawn below the selected tab.
|
|
final BorderSide borderSide;
|
|
|
|
/// Locates the selected tab's underline relative to the tab's boundary.
|
|
///
|
|
/// The [TabBar.indicatorSize] property can be used to define the tab
|
|
/// indicator's bounds in terms of its (centered) tab widget with
|
|
/// [TabBarIndicatorSize.label], or the entire tab with
|
|
/// [TabBarIndicatorSize.tab].
|
|
final EdgeInsetsGeometry insets;
|
|
|
|
@override
|
|
Decoration? lerpFrom(Decoration? a, double t) {
|
|
if (a is VerticalUnderlineTabIndicator) {
|
|
return VerticalUnderlineTabIndicator(
|
|
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
|
|
insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!,
|
|
);
|
|
}
|
|
return super.lerpFrom(a, t);
|
|
}
|
|
|
|
@override
|
|
Decoration? lerpTo(Decoration? b, double t) {
|
|
if (b is VerticalUnderlineTabIndicator) {
|
|
return VerticalUnderlineTabIndicator(
|
|
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
|
|
insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!,
|
|
);
|
|
}
|
|
return super.lerpTo(b, t);
|
|
}
|
|
|
|
@override
|
|
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
|
|
return _VerticalUnderlinePainter(this, borderRadius, onChanged);
|
|
}
|
|
|
|
Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {
|
|
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
|
|
// dom
|
|
return Rect.fromLTWH(
|
|
indicator.left,
|
|
indicator.top,
|
|
borderSide.width,
|
|
indicator.height,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Path getClipPath(Rect rect, TextDirection textDirection) {
|
|
if (borderRadius != null) {
|
|
return Path()..addRRect(
|
|
borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)),
|
|
);
|
|
}
|
|
return Path()..addRect(_indicatorRectFor(rect, textDirection));
|
|
}
|
|
}
|
|
|
|
class _VerticalUnderlinePainter extends BoxPainter {
|
|
_VerticalUnderlinePainter(
|
|
this.decoration,
|
|
this.borderRadius,
|
|
super.onChanged,
|
|
);
|
|
|
|
final VerticalUnderlineTabIndicator decoration;
|
|
final BorderRadius? borderRadius;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
|
assert(configuration.size != null);
|
|
final Rect rect = offset & configuration.size!;
|
|
final TextDirection textDirection = configuration.textDirection!;
|
|
final Paint paint;
|
|
if (borderRadius != null) {
|
|
paint = Paint()..color = decoration.borderSide.color;
|
|
final Rect indicator = decoration._indicatorRectFor(rect, textDirection);
|
|
final RRect rrect = RRect.fromRectAndCorners(
|
|
indicator,
|
|
topLeft: borderRadius!.topLeft,
|
|
topRight: borderRadius!.topRight,
|
|
bottomRight: borderRadius!.bottomRight,
|
|
bottomLeft: borderRadius!.bottomLeft,
|
|
);
|
|
canvas.drawRRect(rrect, paint);
|
|
} else {
|
|
paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square;
|
|
final Rect indicator = decoration
|
|
._indicatorRectFor(rect, textDirection)
|
|
.deflate(decoration.borderSide.width / 2.0);
|
|
// dom
|
|
canvas.drawLine(indicator.topLeft, indicator.bottomLeft, paint);
|
|
}
|
|
}
|
|
}
|