1103 lines
33 KiB
Dart
1103 lines
33 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:math';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/physics.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
const EdgeInsetsGeometry _kHorizontalItemPadding =
|
|
EdgeInsets.symmetric(vertical: 2, horizontal: 3);
|
|
|
|
const Radius _kCornerRadius = Radius.circular(9);
|
|
|
|
const Radius _kThumbRadius = Radius.circular(8);
|
|
|
|
const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: 1);
|
|
|
|
const double _kMinSegmentedControlHeight = 28.0;
|
|
|
|
const EdgeInsets _kSeparatorInset = EdgeInsets.symmetric(vertical: 5);
|
|
|
|
const double _kSeparatorWidth = 1;
|
|
|
|
const double _kMinThumbScale = 0.95;
|
|
|
|
const double _kSegmentMinPadding = 10;
|
|
|
|
const double _kTouchYDistanceThreshold = 50.0 * 50.0;
|
|
|
|
const double _kContentPressedMinOpacity = 0.2;
|
|
|
|
const double _kFontSize = 13.0;
|
|
|
|
const FontWeight _kFontWeight = FontWeight.w500;
|
|
|
|
const FontWeight _kHighlightedFontWeight = FontWeight.w600;
|
|
|
|
const Color _kDisabledContentColor = Color.fromARGB(115, 122, 122, 122);
|
|
|
|
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
|
|
const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
|
|
0,
|
|
1,
|
|
0,
|
|
);
|
|
|
|
const Duration _kSpringAnimationDuration = Duration(milliseconds: 412);
|
|
|
|
const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470);
|
|
|
|
const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200);
|
|
|
|
class CommonTabBar<T extends Object> extends StatefulWidget {
|
|
CommonTabBar({
|
|
super.key,
|
|
required this.children,
|
|
required this.onValueChanged,
|
|
this.disabledChildren = const <Never>{},
|
|
this.groupValue,
|
|
required this.thumbColor,
|
|
this.padding = _kHorizontalItemPadding,
|
|
this.backgroundColor,
|
|
this.proportionalWidth = false,
|
|
}) : assert(children.length >= 2),
|
|
assert(
|
|
groupValue == null || children.keys.contains(groupValue),
|
|
'The groupValue must be either null or one of the keys in the children map.',
|
|
);
|
|
final Map<T, Widget> children;
|
|
final Set<T> disabledChildren;
|
|
final T? groupValue;
|
|
final ValueChanged<T?> onValueChanged;
|
|
final Color? backgroundColor;
|
|
final Color thumbColor;
|
|
final bool proportionalWidth;
|
|
final EdgeInsetsGeometry padding;
|
|
|
|
@override
|
|
State<CommonTabBar<T>> createState() => _CommonTabBarState<T>();
|
|
}
|
|
|
|
class _CommonTabBarState<T extends Object> extends State<CommonTabBar<T>>
|
|
with TickerProviderStateMixin<CommonTabBar<T>> {
|
|
late final AnimationController thumbController = AnimationController(
|
|
duration: _kSpringAnimationDuration,
|
|
value: 0,
|
|
vsync: this,
|
|
);
|
|
Animatable<Rect?>? thumbAnimatable;
|
|
|
|
late final AnimationController thumbScaleController = AnimationController(
|
|
duration: _kSpringAnimationDuration,
|
|
value: 0,
|
|
vsync: this,
|
|
);
|
|
late Animation<double> thumbScaleAnimation = thumbScaleController.drive(
|
|
Tween<double>(begin: 1, end: _kMinThumbScale),
|
|
);
|
|
|
|
final TapGestureRecognizer tap = TapGestureRecognizer();
|
|
final HorizontalDragGestureRecognizer drag =
|
|
HorizontalDragGestureRecognizer();
|
|
final LongPressGestureRecognizer longPress = LongPressGestureRecognizer();
|
|
final GlobalKey segmentedControlRenderWidgetKey = GlobalKey();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final GestureArenaTeam team = GestureArenaTeam();
|
|
longPress.team = team;
|
|
drag.team = team;
|
|
team.captain = drag;
|
|
|
|
drag
|
|
..onDown = onDown
|
|
..onUpdate = onUpdate
|
|
..onEnd = onEnd
|
|
..onCancel = onCancel;
|
|
|
|
tap.onTapUp = onTapUp;
|
|
longPress.onLongPress = () {};
|
|
|
|
highlighted = widget.groupValue;
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(CommonTabBar<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (!isThumbDragging && highlighted != widget.groupValue) {
|
|
thumbController.animateWith(_kThumbSpringAnimationSimulation);
|
|
thumbAnimatable = null;
|
|
highlighted = widget.groupValue;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
thumbScaleController.dispose();
|
|
thumbController.dispose();
|
|
|
|
drag.dispose();
|
|
tap.dispose();
|
|
longPress.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
bool? _startedOnSelectedSegment;
|
|
bool _startedOnDisabledSegment = false;
|
|
|
|
bool get isThumbDragging =>
|
|
(_startedOnSelectedSegment ?? false) && !_startedOnDisabledSegment;
|
|
|
|
T segmentForXPosition(double dx) {
|
|
final BuildContext currentContext =
|
|
segmentedControlRenderWidgetKey.currentContext!;
|
|
final _RenderSegmentedControl<T> renderBox =
|
|
currentContext.findRenderObject()! as _RenderSegmentedControl<T>;
|
|
|
|
final int numOfChildren = widget.children.length;
|
|
assert(renderBox.hasSize);
|
|
assert(numOfChildren >= 2);
|
|
|
|
int segmentIndex = renderBox.getClosestSegmentIndex(dx);
|
|
|
|
switch (Directionality.of(context)) {
|
|
case TextDirection.ltr:
|
|
break;
|
|
case TextDirection.rtl:
|
|
segmentIndex = numOfChildren - 1 - segmentIndex;
|
|
}
|
|
return widget.children.keys.elementAt(segmentIndex);
|
|
}
|
|
|
|
bool _hasDraggedTooFar(DragUpdateDetails details) {
|
|
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
|
|
assert(renderBox.hasSize);
|
|
final Size size = renderBox.size;
|
|
final Offset offCenter =
|
|
details.localPosition - Offset(size.width / 2, size.height / 2);
|
|
final double l2 =
|
|
math.pow(math.max(0.0, offCenter.dx.abs() - size.width / 2), 2) +
|
|
math.pow(math.max(0.0, offCenter.dy.abs() - size.height / 2), 2)
|
|
as double;
|
|
return l2 > _kTouchYDistanceThreshold;
|
|
}
|
|
|
|
void _playThumbScaleAnimation({required bool isExpanding}) {
|
|
thumbScaleAnimation = thumbScaleController.drive(
|
|
Tween<double>(
|
|
begin: thumbScaleAnimation.value,
|
|
end: isExpanding ? 1 : _kMinThumbScale),
|
|
);
|
|
thumbScaleController.animateWith(_kThumbSpringAnimationSimulation);
|
|
}
|
|
|
|
void onHighlightChangedByGesture(T newValue) {
|
|
if (highlighted == newValue) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
highlighted = newValue;
|
|
});
|
|
thumbController.animateWith(_kThumbSpringAnimationSimulation);
|
|
thumbAnimatable = null;
|
|
}
|
|
|
|
void onPressedChangedByGesture(T? newValue) {
|
|
if (pressed != newValue) {
|
|
setState(() {
|
|
pressed = newValue;
|
|
});
|
|
}
|
|
}
|
|
|
|
void onTapUp(TapUpDetails details) {
|
|
if (isThumbDragging) {
|
|
return;
|
|
}
|
|
final T segment = segmentForXPosition(details.localPosition.dx);
|
|
onPressedChangedByGesture(null);
|
|
if (segment != widget.groupValue &&
|
|
!widget.disabledChildren.contains(segment)) {
|
|
widget.onValueChanged(segment);
|
|
}
|
|
}
|
|
|
|
void onDown(DragDownDetails details) {
|
|
final T touchDownSegment = segmentForXPosition(details.localPosition.dx);
|
|
_startedOnSelectedSegment = touchDownSegment == highlighted;
|
|
_startedOnDisabledSegment =
|
|
widget.disabledChildren.contains(touchDownSegment);
|
|
if (widget.disabledChildren.contains(touchDownSegment)) {
|
|
return;
|
|
}
|
|
onPressedChangedByGesture(touchDownSegment);
|
|
|
|
if (isThumbDragging) {
|
|
_playThumbScaleAnimation(isExpanding: false);
|
|
}
|
|
}
|
|
|
|
void onUpdate(DragUpdateDetails details) {
|
|
if (_startedOnDisabledSegment) {
|
|
return;
|
|
}
|
|
final T touchDownSegment = segmentForXPosition(details.localPosition.dx);
|
|
if (widget.disabledChildren.contains(touchDownSegment)) {
|
|
return;
|
|
}
|
|
if (isThumbDragging) {
|
|
onPressedChangedByGesture(touchDownSegment);
|
|
onHighlightChangedByGesture(touchDownSegment);
|
|
} else {
|
|
final T? segment = _hasDraggedTooFar(details)
|
|
? null
|
|
: segmentForXPosition(details.localPosition.dx);
|
|
onPressedChangedByGesture(segment);
|
|
}
|
|
}
|
|
|
|
void onEnd(DragEndDetails details) {
|
|
final T? pressed = this.pressed;
|
|
if (isThumbDragging) {
|
|
_playThumbScaleAnimation(isExpanding: true);
|
|
if (highlighted != widget.groupValue) {
|
|
widget.onValueChanged(highlighted);
|
|
}
|
|
} else if (pressed != null) {
|
|
onHighlightChangedByGesture(pressed);
|
|
assert(pressed == highlighted);
|
|
if (highlighted != widget.groupValue) {
|
|
widget.onValueChanged(highlighted);
|
|
}
|
|
}
|
|
|
|
onPressedChangedByGesture(null);
|
|
_startedOnSelectedSegment = null;
|
|
}
|
|
|
|
void onCancel() {
|
|
if (isThumbDragging) {
|
|
_playThumbScaleAnimation(isExpanding: true);
|
|
}
|
|
onPressedChangedByGesture(null);
|
|
_startedOnSelectedSegment = null;
|
|
}
|
|
|
|
T? highlighted;
|
|
|
|
T? pressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(widget.children.length >= 2);
|
|
List<Widget> children = <Widget>[];
|
|
bool isPreviousSegmentHighlighted = false;
|
|
|
|
int index = 0;
|
|
int? highlightedIndex;
|
|
for (final MapEntry<T, Widget> entry in widget.children.entries) {
|
|
final bool isHighlighted = highlighted == entry.key;
|
|
if (isHighlighted) {
|
|
highlightedIndex = index;
|
|
}
|
|
|
|
if (index != 0) {
|
|
children.add(
|
|
_SegmentSeparator(
|
|
key: ValueKey<int>(index),
|
|
highlighted: isPreviousSegmentHighlighted || isHighlighted,
|
|
),
|
|
);
|
|
}
|
|
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
final _SegmentLocation segmentLocation = switch (textDirection) {
|
|
TextDirection.ltr when index == 0 => _SegmentLocation.leftmost,
|
|
TextDirection.ltr when index == widget.children.length - 1 =>
|
|
_SegmentLocation.rightmost,
|
|
TextDirection.rtl when index == widget.children.length - 1 =>
|
|
_SegmentLocation.leftmost,
|
|
TextDirection.rtl when index == 0 => _SegmentLocation.rightmost,
|
|
TextDirection.ltr || TextDirection.rtl => _SegmentLocation.inbetween,
|
|
};
|
|
children.add(
|
|
Semantics(
|
|
button: true,
|
|
onTap: () {
|
|
if (widget.disabledChildren.contains(entry.key)) {
|
|
return;
|
|
}
|
|
widget.onValueChanged(entry.key);
|
|
},
|
|
inMutuallyExclusiveGroup: true,
|
|
selected: widget.groupValue == entry.key,
|
|
child: MouseRegion(
|
|
cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
|
|
child: _Segment<T>(
|
|
key: ValueKey<T>(entry.key),
|
|
highlighted: isHighlighted,
|
|
pressed: pressed == entry.key,
|
|
isDragging: isThumbDragging,
|
|
enabled: !widget.disabledChildren.contains(entry.key),
|
|
segmentLocation: segmentLocation,
|
|
child: entry.value,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
index += 1;
|
|
isPreviousSegmentHighlighted = isHighlighted;
|
|
}
|
|
|
|
assert((highlightedIndex == null) == (highlighted == null));
|
|
|
|
switch (Directionality.of(context)) {
|
|
case TextDirection.ltr:
|
|
break;
|
|
case TextDirection.rtl:
|
|
children = children.reversed.toList(growable: false);
|
|
if (highlightedIndex != null) {
|
|
highlightedIndex = index - 1 - highlightedIndex;
|
|
}
|
|
}
|
|
|
|
return UnconstrainedBox(
|
|
constrainedAxis: Axis.horizontal,
|
|
child: Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
padding: widget.padding.resolve(Directionality.of(context)),
|
|
decoration: ShapeDecoration(
|
|
shape: RoundedSuperellipseBorder(
|
|
borderRadius: const BorderRadius.all(_kCornerRadius),
|
|
),
|
|
color: widget.backgroundColor,
|
|
),
|
|
child: AnimatedBuilder(
|
|
animation: thumbScaleAnimation,
|
|
builder: (BuildContext context, Widget? child) {
|
|
return _CommonTabBarRenderWidget<T>(
|
|
proportionalWidth: widget.proportionalWidth,
|
|
key: segmentedControlRenderWidgetKey,
|
|
highlightedIndex: highlightedIndex,
|
|
thumbColor: widget.thumbColor,
|
|
thumbScale: thumbScaleAnimation.value,
|
|
state: this,
|
|
children: children,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Segment<T> extends StatefulWidget {
|
|
const _Segment({
|
|
required ValueKey<T> key,
|
|
required this.child,
|
|
required this.pressed,
|
|
required this.highlighted,
|
|
required this.isDragging,
|
|
required this.enabled,
|
|
required this.segmentLocation,
|
|
}) : super(key: key);
|
|
|
|
final Widget child;
|
|
|
|
final bool pressed;
|
|
final bool highlighted;
|
|
final bool enabled;
|
|
final _SegmentLocation segmentLocation;
|
|
final bool isDragging;
|
|
|
|
bool get shouldFadeoutContent => pressed && !highlighted && enabled;
|
|
|
|
bool get shouldScaleContent =>
|
|
pressed && highlighted && isDragging && enabled;
|
|
|
|
@override
|
|
_SegmentState<T> createState() => _SegmentState<T>();
|
|
}
|
|
|
|
class _SegmentState<T> extends State<_Segment<T>>
|
|
with TickerProviderStateMixin<_Segment<T>> {
|
|
late final AnimationController highlightPressScaleController;
|
|
late Animation<double> highlightPressScaleAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
highlightPressScaleController = AnimationController(
|
|
duration: _kOpacityAnimationDuration,
|
|
value: widget.shouldScaleContent ? 1 : 0,
|
|
vsync: this,
|
|
);
|
|
|
|
highlightPressScaleAnimation = highlightPressScaleController.drive(
|
|
Tween<double>(begin: 1.0, end: _kMinThumbScale),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_Segment<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
assert(oldWidget.key == widget.key);
|
|
|
|
if (oldWidget.shouldScaleContent != widget.shouldScaleContent) {
|
|
highlightPressScaleAnimation = highlightPressScaleController.drive(
|
|
Tween<double>(
|
|
begin: highlightPressScaleAnimation.value,
|
|
end: widget.shouldScaleContent ? _kMinThumbScale : 1.0,
|
|
),
|
|
);
|
|
highlightPressScaleController
|
|
.animateWith(_kThumbSpringAnimationSimulation);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
highlightPressScaleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Alignment scaleAlignment = switch (widget.segmentLocation) {
|
|
_SegmentLocation.leftmost => Alignment.centerLeft,
|
|
_SegmentLocation.rightmost => Alignment.centerRight,
|
|
_SegmentLocation.inbetween => Alignment.center,
|
|
};
|
|
|
|
return MetaData(
|
|
behavior: HitTestBehavior.opaque,
|
|
child: IndexedStack(
|
|
alignment: Alignment.center,
|
|
children: <Widget>[
|
|
AnimatedOpacity(
|
|
opacity:
|
|
widget.shouldFadeoutContent ? _kContentPressedMinOpacity : 1,
|
|
duration: _kOpacityAnimationDuration,
|
|
curve: Curves.ease,
|
|
child: AnimatedDefaultTextStyle(
|
|
style: DefaultTextStyle.of(context).style.merge(
|
|
TextStyle(
|
|
fontWeight: widget.highlighted
|
|
? _kHighlightedFontWeight
|
|
: _kFontWeight,
|
|
fontSize: _kFontSize,
|
|
color: widget.enabled ? null : _kDisabledContentColor,
|
|
),
|
|
),
|
|
duration: _kHighlightAnimationDuration,
|
|
curve: Curves.ease,
|
|
child: ScaleTransition(
|
|
alignment: scaleAlignment,
|
|
scale: highlightPressScaleAnimation,
|
|
child: widget.child,
|
|
),
|
|
),
|
|
),
|
|
DefaultTextStyle.merge(
|
|
style: const TextStyle(
|
|
fontWeight: _kHighlightedFontWeight, fontSize: _kFontSize),
|
|
child: widget.child,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SegmentSeparator extends StatefulWidget {
|
|
const _SegmentSeparator({
|
|
required ValueKey<int> key,
|
|
required this.highlighted,
|
|
}) : super(key: key);
|
|
|
|
final bool highlighted;
|
|
|
|
@override
|
|
_SegmentSeparatorState createState() => _SegmentSeparatorState();
|
|
}
|
|
|
|
class _SegmentSeparatorState extends State<_SegmentSeparator>
|
|
with TickerProviderStateMixin<_SegmentSeparator> {
|
|
late final AnimationController separatorOpacityController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
separatorOpacityController = AnimationController(
|
|
duration: _kSpringAnimationDuration,
|
|
value: widget.highlighted ? 0 : 1,
|
|
vsync: this,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_SegmentSeparator oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
assert(oldWidget.key == widget.key);
|
|
|
|
if (oldWidget.highlighted != widget.highlighted) {
|
|
separatorOpacityController.animateTo(
|
|
widget.highlighted ? 0 : 1,
|
|
duration: _kSpringAnimationDuration,
|
|
curve: Curves.ease,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
separatorOpacityController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: separatorOpacityController,
|
|
child: const SizedBox(width: _kSeparatorWidth),
|
|
builder: (BuildContext context, Widget? child) {
|
|
return Padding(
|
|
padding: _kSeparatorInset,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.transparent,
|
|
),
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CommonTabBarRenderWidget<T extends Object>
|
|
extends MultiChildRenderObjectWidget {
|
|
const _CommonTabBarRenderWidget({
|
|
super.key,
|
|
super.children,
|
|
required this.highlightedIndex,
|
|
required this.thumbColor,
|
|
required this.thumbScale,
|
|
required this.state,
|
|
required this.proportionalWidth,
|
|
});
|
|
|
|
final int? highlightedIndex;
|
|
final Color thumbColor;
|
|
final double thumbScale;
|
|
final bool proportionalWidth;
|
|
final _CommonTabBarState<T> state;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) {
|
|
return _RenderSegmentedControl<T>(
|
|
highlightedIndex: highlightedIndex,
|
|
thumbColor: thumbColor,
|
|
thumbScale: thumbScale,
|
|
proportionalWidth: proportionalWidth,
|
|
state: state,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context, _RenderSegmentedControl<T> renderObject) {
|
|
assert(renderObject.state == state);
|
|
renderObject
|
|
..thumbColor = thumbColor
|
|
..thumbScale = thumbScale
|
|
..highlightedIndex = highlightedIndex
|
|
..proportionalWidth = proportionalWidth;
|
|
}
|
|
}
|
|
|
|
class _SegmentedControlContainerBoxParentData
|
|
extends ContainerBoxParentData<RenderBox> {}
|
|
|
|
enum _SegmentLocation { leftmost, rightmost, inbetween }
|
|
|
|
class _RenderSegmentedControl<T extends Object> extends RenderBox
|
|
with
|
|
ContainerRenderObjectMixin<RenderBox,
|
|
ContainerBoxParentData<RenderBox>>,
|
|
RenderBoxContainerDefaultsMixin<RenderBox,
|
|
ContainerBoxParentData<RenderBox>> {
|
|
_RenderSegmentedControl({
|
|
required int? highlightedIndex,
|
|
required Color thumbColor,
|
|
required double thumbScale,
|
|
required bool proportionalWidth,
|
|
required this.state,
|
|
}) : _highlightedIndex = highlightedIndex,
|
|
_thumbColor = thumbColor,
|
|
_thumbScale = thumbScale,
|
|
_proportionalWidth = proportionalWidth;
|
|
|
|
final _CommonTabBarState<T> state;
|
|
|
|
Rect? currentThumbRect;
|
|
|
|
@override
|
|
void attach(PipelineOwner owner) {
|
|
super.attach(owner);
|
|
state.thumbController.addListener(markNeedsPaint);
|
|
}
|
|
|
|
@override
|
|
void detach() {
|
|
state.thumbController.removeListener(markNeedsPaint);
|
|
super.detach();
|
|
}
|
|
|
|
double get thumbScale => _thumbScale;
|
|
double _thumbScale;
|
|
|
|
set thumbScale(double value) {
|
|
if (_thumbScale == value) {
|
|
return;
|
|
}
|
|
|
|
_thumbScale = value;
|
|
if (state.highlighted != null) {
|
|
markNeedsPaint();
|
|
}
|
|
}
|
|
|
|
int? get highlightedIndex => _highlightedIndex;
|
|
int? _highlightedIndex;
|
|
|
|
set highlightedIndex(int? value) {
|
|
if (_highlightedIndex == value) {
|
|
return;
|
|
}
|
|
|
|
_highlightedIndex = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
Color get thumbColor => _thumbColor;
|
|
Color _thumbColor;
|
|
|
|
set thumbColor(Color value) {
|
|
if (_thumbColor == value) {
|
|
return;
|
|
}
|
|
_thumbColor = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
bool get proportionalWidth => _proportionalWidth;
|
|
bool _proportionalWidth;
|
|
|
|
set proportionalWidth(bool value) {
|
|
if (_proportionalWidth == value) {
|
|
return;
|
|
}
|
|
_proportionalWidth = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
@override
|
|
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
|
|
assert(debugHandleEvent(event, entry));
|
|
if (event is PointerDownEvent && !state.isThumbDragging) {
|
|
state.tap.addPointer(event);
|
|
state.longPress.addPointer(event);
|
|
state.drag.addPointer(event);
|
|
}
|
|
}
|
|
|
|
double get separatorWidth => _kSeparatorInset.horizontal + _kSeparatorWidth;
|
|
|
|
double get totalSeparatorWidth => separatorWidth * (childCount ~/ 2);
|
|
|
|
int getClosestSegmentIndex(double dx) {
|
|
int index = 0;
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
child.parentData! as _SegmentedControlContainerBoxParentData;
|
|
final double clampX = clampDouble(
|
|
dx,
|
|
childParentData.offset.dx,
|
|
child.size.width + childParentData.offset.dx,
|
|
);
|
|
|
|
if (dx <= clampX) {
|
|
break;
|
|
}
|
|
|
|
index++;
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
|
|
final int segmentCount = childCount ~/ 2 + 1;
|
|
return min(index, segmentCount - 1);
|
|
}
|
|
|
|
RenderBox? nonSeparatorChildAfter(RenderBox child) {
|
|
final RenderBox? nextChild = childAfter(child);
|
|
return nextChild == null ? null : childAfter(nextChild);
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) {
|
|
final int childCount = this.childCount ~/ 2 + 1;
|
|
RenderBox? child = firstChild;
|
|
double maxMinChildWidth = 0;
|
|
while (child != null) {
|
|
final double childWidth = child.getMinIntrinsicWidth(height);
|
|
maxMinChildWidth = math.max(maxMinChildWidth, childWidth);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount +
|
|
totalSeparatorWidth;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
final int childCount = this.childCount ~/ 2 + 1;
|
|
RenderBox? child = firstChild;
|
|
double maxMaxChildWidth = 0;
|
|
while (child != null) {
|
|
final double childWidth = child.getMaxIntrinsicWidth(height);
|
|
maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount +
|
|
totalSeparatorWidth;
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicHeight(double width) {
|
|
RenderBox? child = firstChild;
|
|
double maxMinChildHeight = _kMinSegmentedControlHeight;
|
|
while (child != null) {
|
|
final double childHeight = child.getMinIntrinsicHeight(width);
|
|
maxMinChildHeight = math.max(maxMinChildHeight, childHeight);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return maxMinChildHeight;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicHeight(double width) {
|
|
RenderBox? child = firstChild;
|
|
double maxMaxChildHeight = _kMinSegmentedControlHeight;
|
|
while (child != null) {
|
|
final double childHeight = child.getMaxIntrinsicHeight(width);
|
|
maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return maxMaxChildHeight;
|
|
}
|
|
|
|
@override
|
|
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
|
return defaultComputeDistanceToHighestActualBaseline(baseline);
|
|
}
|
|
|
|
@override
|
|
void setupParentData(RenderBox child) {
|
|
if (child.parentData is! _SegmentedControlContainerBoxParentData) {
|
|
child.parentData = _SegmentedControlContainerBoxParentData();
|
|
}
|
|
}
|
|
|
|
double _getMaxChildHeight(BoxConstraints constraints, double childWidth) {
|
|
double maxHeight = _kMinSegmentedControlHeight;
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
final double boxHeight = child.getMaxIntrinsicHeight(childWidth);
|
|
maxHeight = math.max(maxHeight, boxHeight);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return maxHeight;
|
|
}
|
|
|
|
double _getMaxChildWidth(BoxConstraints constraints) {
|
|
final int childCount = this.childCount ~/ 2 + 1;
|
|
double childWidth =
|
|
(constraints.minWidth - totalSeparatorWidth) / childCount;
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
childWidth = math.max(
|
|
childWidth,
|
|
child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding,
|
|
);
|
|
child = nonSeparatorChildAfter(child);
|
|
}
|
|
return math.min(
|
|
childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount);
|
|
}
|
|
|
|
List<double> _getChildWidths(BoxConstraints constraints) {
|
|
if (!proportionalWidth) {
|
|
final double maxChildWidth = _getMaxChildWidth(constraints);
|
|
final int segmentCount = childCount ~/ 2 + 1;
|
|
return List<double>.filled(segmentCount, maxChildWidth);
|
|
}
|
|
|
|
final List<double> segmentWidths = <double>[];
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
final double childWidth =
|
|
child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding;
|
|
child = nonSeparatorChildAfter(child);
|
|
segmentWidths.add(childWidth);
|
|
}
|
|
|
|
final double totalWidth = segmentWidths.sum;
|
|
final double allowedMaxWidth = constraints.maxWidth - totalSeparatorWidth;
|
|
final double allowedMinWidth = constraints.minWidth - totalSeparatorWidth;
|
|
|
|
final double scale =
|
|
clampDouble(totalWidth, allowedMinWidth, allowedMaxWidth) / totalWidth;
|
|
if (scale != 1) {
|
|
for (int i = 0; i < segmentWidths.length; i++) {
|
|
segmentWidths[i] = segmentWidths[i] * scale;
|
|
}
|
|
}
|
|
return segmentWidths;
|
|
}
|
|
|
|
Size _computeOverallSize(BoxConstraints constraints) {
|
|
final double maxChildHeight =
|
|
_getMaxChildHeight(constraints, constraints.maxWidth);
|
|
return constraints.constrain(
|
|
Size(_getChildWidths(constraints).sum + totalSeparatorWidth,
|
|
maxChildHeight),
|
|
);
|
|
}
|
|
|
|
@override
|
|
double? computeDryBaseline(
|
|
covariant BoxConstraints constraints, TextBaseline baseline) {
|
|
final List<double> segmentWidths = _getChildWidths(constraints);
|
|
final double childHeight =
|
|
_getMaxChildHeight(constraints, constraints.maxWidth);
|
|
|
|
int index = 0;
|
|
BaselineOffset baselineOffset = BaselineOffset.noBaseline;
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
final BoxConstraints childConstraints = BoxConstraints.tight(
|
|
Size(segmentWidths[index], childHeight),
|
|
);
|
|
baselineOffset = baselineOffset.minOf(
|
|
BaselineOffset(child.getDryBaseline(childConstraints, baseline)),
|
|
);
|
|
|
|
child = nonSeparatorChildAfter(child);
|
|
index++;
|
|
}
|
|
|
|
return baselineOffset.offset;
|
|
}
|
|
|
|
@override
|
|
Size computeDryLayout(BoxConstraints constraints) {
|
|
return _computeOverallSize(constraints);
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
final BoxConstraints constraints = this.constraints;
|
|
final List<double> segmentWidths = _getChildWidths(constraints);
|
|
|
|
final double childHeight = _getMaxChildHeight(constraints, double.infinity);
|
|
final BoxConstraints separatorConstraints = BoxConstraints(
|
|
minHeight: childHeight,
|
|
maxHeight: childHeight,
|
|
);
|
|
RenderBox? child = firstChild;
|
|
int index = 0;
|
|
double start = 0;
|
|
while (child != null) {
|
|
final BoxConstraints childConstraints = BoxConstraints.tight(
|
|
Size(segmentWidths[index ~/ 2], childHeight),
|
|
);
|
|
child.layout(index.isEven ? childConstraints : separatorConstraints,
|
|
parentUsesSize: true);
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
child.parentData! as _SegmentedControlContainerBoxParentData;
|
|
final Offset childOffset = Offset(start, 0);
|
|
childParentData.offset = childOffset;
|
|
start += child.size.width;
|
|
assert(
|
|
index.isEven ||
|
|
child.size.width == _kSeparatorWidth + _kSeparatorInset.horizontal,
|
|
'${child.size.width} != ${_kSeparatorWidth + _kSeparatorInset.horizontal}',
|
|
);
|
|
child = childAfter(child);
|
|
index += 1;
|
|
}
|
|
size = _computeOverallSize(constraints);
|
|
}
|
|
|
|
Rect? moveThumbRectInBound(Rect? thumbRect, List<RenderBox> children) {
|
|
assert(hasSize);
|
|
assert(children.length >= 2);
|
|
if (thumbRect == null) {
|
|
return null;
|
|
}
|
|
|
|
final Offset firstChildOffset =
|
|
(children.first.parentData! as _SegmentedControlContainerBoxParentData)
|
|
.offset;
|
|
final double leftMost = firstChildOffset.dx;
|
|
final double rightMost =
|
|
(children.last.parentData! as _SegmentedControlContainerBoxParentData)
|
|
.offset
|
|
.dx +
|
|
children.last.size.width;
|
|
assert(rightMost > leftMost);
|
|
return Rect.fromLTRB(
|
|
math.max(thumbRect.left, leftMost - _kThumbInsets.left),
|
|
firstChildOffset.dy - _kThumbInsets.top,
|
|
math.min(thumbRect.right, rightMost + _kThumbInsets.right),
|
|
firstChildOffset.dy + children.first.size.height + _kThumbInsets.bottom,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
final List<RenderBox> children = getChildrenAsList();
|
|
for (int index = 1; index < childCount; index += 2) {
|
|
_paintSeparator(context, offset, children[index]);
|
|
}
|
|
|
|
final int? highlightedChildIndex = highlightedIndex;
|
|
if (highlightedChildIndex != null) {
|
|
final RenderBox selectedChild = children[highlightedChildIndex * 2];
|
|
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
selectedChild.parentData! as _SegmentedControlContainerBoxParentData;
|
|
final Rect newThumbRect = _kThumbInsets.inflateRect(
|
|
childParentData.offset & selectedChild.size,
|
|
);
|
|
if (state.thumbController.isAnimating) {
|
|
final Animatable<Rect?>? thumbTween = state.thumbAnimatable;
|
|
if (thumbTween == null) {
|
|
final Rect startingRect =
|
|
moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect;
|
|
state.thumbAnimatable =
|
|
RectTween(begin: startingRect, end: newThumbRect);
|
|
} else if (newThumbRect != thumbTween.transform(1)) {
|
|
final Rect startingRect =
|
|
moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect;
|
|
state.thumbAnimatable = RectTween(
|
|
begin: startingRect,
|
|
end: newThumbRect,
|
|
).chain(CurveTween(curve: Interval(state.thumbController.value, 1)));
|
|
}
|
|
} else {
|
|
state.thumbAnimatable = null;
|
|
}
|
|
|
|
final Rect unscaledThumbRect =
|
|
state.thumbAnimatable?.evaluate(state.thumbController) ??
|
|
newThumbRect;
|
|
currentThumbRect = unscaledThumbRect;
|
|
|
|
final _SegmentLocation childLocation;
|
|
if (highlightedChildIndex == 0) {
|
|
childLocation = _SegmentLocation.leftmost;
|
|
} else if (highlightedChildIndex == children.length ~/ 2) {
|
|
childLocation = _SegmentLocation.rightmost;
|
|
} else {
|
|
childLocation = _SegmentLocation.inbetween;
|
|
}
|
|
final double delta = switch (childLocation) {
|
|
_SegmentLocation.leftmost =>
|
|
unscaledThumbRect.width - unscaledThumbRect.width * thumbScale,
|
|
_SegmentLocation.rightmost =>
|
|
unscaledThumbRect.width * thumbScale - unscaledThumbRect.width,
|
|
_SegmentLocation.inbetween => 0,
|
|
};
|
|
final Rect thumbRect = Rect.fromCenter(
|
|
center: unscaledThumbRect.center - Offset(delta / 2, 0),
|
|
width: unscaledThumbRect.width * thumbScale,
|
|
height: unscaledThumbRect.height * thumbScale,
|
|
);
|
|
|
|
_paintThumb(context, offset, thumbRect);
|
|
} else {
|
|
currentThumbRect = null;
|
|
}
|
|
|
|
for (int index = 0; index < children.length; index += 2) {
|
|
_paintChild(context, offset, children[index]);
|
|
}
|
|
}
|
|
|
|
final Paint separatorPaint = Paint();
|
|
|
|
void _paintSeparator(
|
|
PaintingContext context, Offset offset, RenderBox child) {
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
child.parentData! as _SegmentedControlContainerBoxParentData;
|
|
context.paintChild(child, offset + childParentData.offset);
|
|
}
|
|
|
|
void _paintChild(PaintingContext context, Offset offset, RenderBox child) {
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
child.parentData! as _SegmentedControlContainerBoxParentData;
|
|
context.paintChild(child, childParentData.offset + offset);
|
|
}
|
|
|
|
void _paintThumb(PaintingContext context, Offset offset, Rect thumbRect) {
|
|
// const List<BoxShadow> thumbShadow = <BoxShadow>[
|
|
// BoxShadow(color: Color(0x1F000000), offset: Offset(0, 3), blurRadius: 8),
|
|
// BoxShadow(color: Color(0x0A000000), offset: Offset(0, 3), blurRadius: 1),
|
|
// ];
|
|
|
|
final RRect thumbRRect =
|
|
RRect.fromRectAndRadius(thumbRect.shift(offset), _kThumbRadius);
|
|
|
|
// for (final BoxShadow shadow in thumbShadow) {
|
|
// context.canvas
|
|
// .drawRRect(thumbRRect.shift(shadow.offset), shadow.toPaint());
|
|
// }
|
|
|
|
context.canvas.drawRRect(
|
|
thumbRRect.inflate(0.5), Paint()..color = const Color(0x0A000000));
|
|
|
|
context.canvas.drawRRect(thumbRRect, Paint()..color = thumbColor);
|
|
}
|
|
|
|
@override
|
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
|
RenderBox? child = lastChild;
|
|
while (child != null) {
|
|
final _SegmentedControlContainerBoxParentData childParentData =
|
|
child.parentData! as _SegmentedControlContainerBoxParentData;
|
|
if ((childParentData.offset & child.size).contains(position)) {
|
|
return result.addWithPaintOffset(
|
|
offset: childParentData.offset,
|
|
position: position,
|
|
hitTest: (BoxHitTestResult result, Offset localOffset) {
|
|
assert(localOffset == position - childParentData.offset);
|
|
return child!.hitTest(result, position: localOffset);
|
|
},
|
|
);
|
|
}
|
|
child = childParentData.previousSibling;
|
|
}
|
|
return false;
|
|
}
|
|
}
|