642 lines
18 KiB
Dart
642 lines
18 KiB
Dart
import 'dart:ui';
|
|
|
|
import 'package:fl_clash/common/color.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
const Duration _bottomSheetEnterDuration = Duration(milliseconds: 300);
|
|
const Duration _bottomSheetExitDuration = Duration(milliseconds: 200);
|
|
const Curve _modalBottomSheetCurve = Easing.standardDecelerate;
|
|
const double _defaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0;
|
|
|
|
class SideSheet extends StatefulWidget {
|
|
const SideSheet({
|
|
super.key,
|
|
this.animationController,
|
|
this.enableDrag = true,
|
|
this.showDragHandle,
|
|
this.dragHandleColor,
|
|
this.dragHandleSize,
|
|
this.onDragStart,
|
|
this.onDragEnd,
|
|
this.backgroundColor,
|
|
this.shadowColor,
|
|
this.elevation,
|
|
this.shape,
|
|
this.clipBehavior,
|
|
this.constraints,
|
|
required this.onClosing,
|
|
required this.builder,
|
|
}) : assert(elevation == null || elevation >= 0.0);
|
|
|
|
final AnimationController? animationController;
|
|
|
|
final VoidCallback onClosing;
|
|
|
|
final WidgetBuilder builder;
|
|
|
|
final bool enableDrag;
|
|
|
|
final bool? showDragHandle;
|
|
|
|
final Color? dragHandleColor;
|
|
|
|
final Size? dragHandleSize;
|
|
|
|
final BottomSheetDragStartHandler? onDragStart;
|
|
|
|
final BottomSheetDragEndHandler? onDragEnd;
|
|
|
|
final Color? backgroundColor;
|
|
|
|
final Color? shadowColor;
|
|
|
|
final double? elevation;
|
|
|
|
final ShapeBorder? shape;
|
|
|
|
final Clip? clipBehavior;
|
|
|
|
final BoxConstraints? constraints;
|
|
|
|
@override
|
|
State<SideSheet> createState() => _SideSheetState();
|
|
|
|
static AnimationController createAnimationController(TickerProvider vsync) {
|
|
return AnimationController(
|
|
duration: _bottomSheetEnterDuration,
|
|
reverseDuration: _bottomSheetExitDuration,
|
|
debugLabel: 'SideSheet',
|
|
vsync: vsync,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SideSheetState extends State<SideSheet> {
|
|
final GlobalKey _childKey = GlobalKey(debugLabel: 'SideSheet child');
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final Color color = widget.backgroundColor ?? colorScheme.surface;
|
|
final Color surfaceTintColor = colorScheme.surfaceTint;
|
|
final Color shadowColor = widget.shadowColor ?? Colors.transparent;
|
|
final double elevation = widget.elevation ?? 0;
|
|
final ShapeBorder shape = widget.shape ??
|
|
RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(0),
|
|
);
|
|
|
|
final BoxConstraints constraints = widget.constraints ??
|
|
const BoxConstraints(maxWidth: 320, minWidth: 320);
|
|
|
|
final Clip clipBehavior = widget.clipBehavior ?? Clip.none;
|
|
|
|
Widget sideSheet = Material(
|
|
key: _childKey,
|
|
color: color,
|
|
elevation: elevation,
|
|
surfaceTintColor: surfaceTintColor,
|
|
shadowColor: shadowColor,
|
|
shape: shape,
|
|
clipBehavior: clipBehavior,
|
|
child: widget.builder(context),
|
|
);
|
|
|
|
return ConstrainedBox(
|
|
constraints: constraints,
|
|
child: sideSheet,
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef _SizeChangeCallback<Size> = void Function(Size);
|
|
|
|
class _SideSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget {
|
|
const _SideSheetLayoutWithSizeListener({
|
|
required this.onChildSizeChanged,
|
|
required this.animationValue,
|
|
required this.isScrollControlled,
|
|
required this.scrollControlDisabledMaxHeightRatio,
|
|
super.child,
|
|
});
|
|
|
|
final _SizeChangeCallback<Size> onChildSizeChanged;
|
|
final double animationValue;
|
|
final bool isScrollControlled;
|
|
final double scrollControlDisabledMaxHeightRatio;
|
|
|
|
@override
|
|
_RenderSideSheetLayoutWithSizeListener createRenderObject(
|
|
BuildContext context) {
|
|
return _RenderSideSheetLayoutWithSizeListener(
|
|
onChildSizeChanged: onChildSizeChanged,
|
|
animationValue: animationValue,
|
|
isScrollControlled: isScrollControlled,
|
|
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(BuildContext context,
|
|
_RenderSideSheetLayoutWithSizeListener renderObject) {
|
|
renderObject.onChildSizeChanged = onChildSizeChanged;
|
|
renderObject.animationValue = animationValue;
|
|
renderObject.isScrollControlled = isScrollControlled;
|
|
renderObject.scrollControlDisabledMaxHeightRatio =
|
|
scrollControlDisabledMaxHeightRatio;
|
|
}
|
|
}
|
|
|
|
class _RenderSideSheetLayoutWithSizeListener extends RenderShiftedBox {
|
|
_RenderSideSheetLayoutWithSizeListener({
|
|
RenderBox? child,
|
|
required _SizeChangeCallback<Size> onChildSizeChanged,
|
|
required double animationValue,
|
|
required bool isScrollControlled,
|
|
required double scrollControlDisabledMaxHeightRatio,
|
|
}) : _onChildSizeChanged = onChildSizeChanged,
|
|
_animationValue = animationValue,
|
|
_isScrollControlled = isScrollControlled,
|
|
_scrollControlDisabledMaxHeightRatio =
|
|
scrollControlDisabledMaxHeightRatio,
|
|
super(child);
|
|
|
|
Size _lastSize = Size.zero;
|
|
|
|
_SizeChangeCallback<Size> get onChildSizeChanged => _onChildSizeChanged;
|
|
_SizeChangeCallback<Size> _onChildSizeChanged;
|
|
|
|
set onChildSizeChanged(_SizeChangeCallback<Size> newCallback) {
|
|
if (_onChildSizeChanged == newCallback) {
|
|
return;
|
|
}
|
|
|
|
_onChildSizeChanged = newCallback;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
double get animationValue => _animationValue;
|
|
double _animationValue;
|
|
|
|
set animationValue(double newValue) {
|
|
if (_animationValue == newValue) {
|
|
return;
|
|
}
|
|
|
|
_animationValue = newValue;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
bool get isScrollControlled => _isScrollControlled;
|
|
bool _isScrollControlled;
|
|
|
|
set isScrollControlled(bool newValue) {
|
|
if (_isScrollControlled == newValue) {
|
|
return;
|
|
}
|
|
|
|
_isScrollControlled = newValue;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
double get scrollControlDisabledMaxHeightRatio =>
|
|
_scrollControlDisabledMaxHeightRatio;
|
|
double _scrollControlDisabledMaxHeightRatio;
|
|
|
|
set scrollControlDisabledMaxHeightRatio(double newValue) {
|
|
if (_scrollControlDisabledMaxHeightRatio == newValue) {
|
|
return;
|
|
}
|
|
|
|
_scrollControlDisabledMaxHeightRatio = newValue;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
Size _getSize(BoxConstraints constraints) {
|
|
return constraints.constrain(constraints.biggest);
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) {
|
|
final double width =
|
|
_getSize(BoxConstraints.tightForFinite(height: height)).width;
|
|
if (width.isFinite) {
|
|
return width;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
final double width =
|
|
_getSize(BoxConstraints.tightForFinite(height: height)).width;
|
|
if (width.isFinite) {
|
|
return width;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicHeight(double width) {
|
|
final double height =
|
|
_getSize(BoxConstraints.tightForFinite(width: width)).height;
|
|
if (height.isFinite) {
|
|
return height;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicHeight(double width) {
|
|
final double height =
|
|
_getSize(BoxConstraints.tightForFinite(width: width)).height;
|
|
if (height.isFinite) {
|
|
return height;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
@override
|
|
Size computeDryLayout(BoxConstraints constraints) {
|
|
return _getSize(constraints);
|
|
}
|
|
|
|
BoxConstraints _getConstraintsForChild(BoxConstraints constraints) {
|
|
return BoxConstraints(
|
|
maxHeight: constraints.maxHeight,
|
|
);
|
|
}
|
|
|
|
Offset _getPositionForChild(Size size, Size childSize) {
|
|
return Offset(size.width - childSize.width * animationValue, 0.0);
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
size = _getSize(constraints);
|
|
if (child != null) {
|
|
final BoxConstraints childConstraints =
|
|
_getConstraintsForChild(constraints);
|
|
assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true));
|
|
child!.layout(
|
|
childConstraints,
|
|
parentUsesSize: !childConstraints.isTight,
|
|
);
|
|
final BoxParentData childParentData = child!.parentData! as BoxParentData;
|
|
childParentData.offset = _getPositionForChild(
|
|
size,
|
|
childConstraints.isTight ? childConstraints.smallest : child!.size,
|
|
);
|
|
final Size childSize =
|
|
childConstraints.isTight ? childConstraints.smallest : child!.size;
|
|
|
|
if (_lastSize != childSize) {
|
|
_lastSize = childSize;
|
|
_onChildSizeChanged.call(_lastSize);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class _ModalSideSheet<T> extends StatefulWidget {
|
|
const _ModalSideSheet({
|
|
super.key,
|
|
required this.route,
|
|
this.backgroundColor,
|
|
this.elevation,
|
|
this.shape,
|
|
this.clipBehavior,
|
|
this.constraints,
|
|
this.isScrollControlled = false,
|
|
this.scrollControlDisabledMaxHeightRatio =
|
|
_defaultScrollControlDisabledMaxHeightRatio,
|
|
this.enableDrag = true,
|
|
this.showDragHandle = false,
|
|
});
|
|
|
|
final ModalSideSheetRoute<T> route;
|
|
final bool isScrollControlled;
|
|
final double scrollControlDisabledMaxHeightRatio;
|
|
final Color? backgroundColor;
|
|
final double? elevation;
|
|
final ShapeBorder? shape;
|
|
final Clip? clipBehavior;
|
|
final BoxConstraints? constraints;
|
|
final bool enableDrag;
|
|
final bool showDragHandle;
|
|
|
|
@override
|
|
_ModalSideSheetState<T> createState() => _ModalSideSheetState<T>();
|
|
}
|
|
|
|
class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
|
ParametricCurve<double> animationCurve = _modalBottomSheetCurve;
|
|
|
|
String _getRouteLabel(MaterialLocalizations localizations) {
|
|
switch (Theme.of(context).platform) {
|
|
case TargetPlatform.iOS:
|
|
case TargetPlatform.macOS:
|
|
return '';
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
return localizations.dialogLabel;
|
|
}
|
|
}
|
|
|
|
EdgeInsets _getNewClipDetails(Size topLayerSize) {
|
|
return EdgeInsets.fromLTRB(0, 0, 0, topLayerSize.height);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMediaQuery(context));
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
final String routeLabel = _getRouteLabel(localizations);
|
|
|
|
return AnimatedBuilder(
|
|
animation: widget.route.animation!,
|
|
child: SideSheet(
|
|
animationController: widget.route._animationController,
|
|
onClosing: () {
|
|
if (widget.route.isCurrent) {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
builder: widget.route.builder,
|
|
backgroundColor: widget.backgroundColor,
|
|
elevation: widget.elevation,
|
|
shape: widget.shape,
|
|
clipBehavior: widget.clipBehavior,
|
|
constraints: widget.constraints,
|
|
enableDrag: widget.enableDrag,
|
|
showDragHandle: widget.showDragHandle,
|
|
),
|
|
builder: (BuildContext context, Widget? child) {
|
|
final double animationValue = animationCurve.transform(
|
|
widget.route.animation!.value,
|
|
);
|
|
return Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
label: routeLabel,
|
|
explicitChildNodes: true,
|
|
child: ClipRect(
|
|
child: _SideSheetLayoutWithSizeListener(
|
|
onChildSizeChanged: (Size size) {
|
|
widget.route._didChangeBarrierSemanticsClip(
|
|
_getNewClipDetails(size),
|
|
);
|
|
},
|
|
animationValue: animationValue,
|
|
isScrollControlled: widget.isScrollControlled,
|
|
scrollControlDisabledMaxHeightRatio:
|
|
widget.scrollControlDisabledMaxHeightRatio,
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ModalSideSheetRoute<T> extends PopupRoute<T> {
|
|
ModalSideSheetRoute(
|
|
{required this.builder,
|
|
this.capturedThemes,
|
|
this.barrierLabel,
|
|
this.barrierOnTapHint,
|
|
this.backgroundColor,
|
|
this.elevation,
|
|
this.shape,
|
|
this.clipBehavior,
|
|
this.constraints,
|
|
this.modalBarrierColor,
|
|
this.isDismissible = true,
|
|
this.isScrollControlled = false,
|
|
this.scrollControlDisabledMaxHeightRatio =
|
|
_defaultScrollControlDisabledMaxHeightRatio,
|
|
super.settings,
|
|
this.transitionAnimationController,
|
|
this.anchorPoint,
|
|
this.useSafeArea = false,
|
|
super.filter});
|
|
|
|
final WidgetBuilder builder;
|
|
|
|
final CapturedThemes? capturedThemes;
|
|
|
|
final bool isScrollControlled;
|
|
|
|
final double scrollControlDisabledMaxHeightRatio;
|
|
|
|
final Color? backgroundColor;
|
|
|
|
final double? elevation;
|
|
|
|
final ShapeBorder? shape;
|
|
|
|
final Clip? clipBehavior;
|
|
|
|
final BoxConstraints? constraints;
|
|
|
|
final Color? modalBarrierColor;
|
|
|
|
final bool isDismissible;
|
|
|
|
final AnimationController? transitionAnimationController;
|
|
|
|
final Offset? anchorPoint;
|
|
|
|
final bool useSafeArea;
|
|
|
|
final String? barrierOnTapHint;
|
|
|
|
final ValueNotifier<EdgeInsets> _clipDetailsNotifier =
|
|
ValueNotifier<EdgeInsets>(EdgeInsets.zero);
|
|
|
|
@override
|
|
void dispose() {
|
|
_clipDetailsNotifier.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _didChangeBarrierSemanticsClip(EdgeInsets newClipDetails) {
|
|
if (_clipDetailsNotifier.value == newClipDetails) {
|
|
return false;
|
|
}
|
|
_clipDetailsNotifier.value = newClipDetails;
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Duration get transitionDuration => _bottomSheetEnterDuration;
|
|
|
|
@override
|
|
Duration get reverseTransitionDuration => _bottomSheetExitDuration;
|
|
|
|
@override
|
|
bool get barrierDismissible => isDismissible;
|
|
|
|
@override
|
|
final String? barrierLabel;
|
|
|
|
@override
|
|
Color get barrierColor => modalBarrierColor ?? Colors.black54;
|
|
|
|
AnimationController? _animationController;
|
|
|
|
@override
|
|
AnimationController createAnimationController() {
|
|
assert(_animationController == null);
|
|
if (transitionAnimationController != null) {
|
|
_animationController = transitionAnimationController;
|
|
willDisposeAnimationController = false;
|
|
} else {
|
|
_animationController = SideSheet.createAnimationController(navigator!);
|
|
}
|
|
return _animationController!;
|
|
}
|
|
|
|
@override
|
|
Widget buildPage(BuildContext context, Animation<double> animation,
|
|
Animation<double> secondaryAnimation) {
|
|
final Widget content = DisplayFeatureSubScreen(
|
|
anchorPoint: anchorPoint,
|
|
child: Builder(
|
|
builder: (BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return _ModalSideSheet<T>(
|
|
route: this,
|
|
backgroundColor: backgroundColor ?? colorScheme.surface,
|
|
elevation: elevation ?? 0,
|
|
shape: shape,
|
|
clipBehavior: clipBehavior,
|
|
constraints: constraints,
|
|
isScrollControlled: isScrollControlled,
|
|
scrollControlDisabledMaxHeightRatio:
|
|
scrollControlDisabledMaxHeightRatio,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
final Widget sideSheet = content;
|
|
|
|
return capturedThemes?.wrap(sideSheet) ?? sideSheet;
|
|
}
|
|
|
|
@override
|
|
Widget buildModalBarrier() {
|
|
if (barrierColor.a != 0 && !offstage) {
|
|
assert(barrierColor != barrierColor.opacity0);
|
|
final Animation<Color?> color = animation!.drive(
|
|
ColorTween(
|
|
begin: barrierColor.opacity0,
|
|
end: barrierColor,
|
|
).chain(
|
|
CurveTween(curve: barrierCurve),
|
|
),
|
|
);
|
|
return AnimatedModalBarrier(
|
|
color: color,
|
|
dismissible: barrierDismissible,
|
|
semanticsLabel: barrierLabel,
|
|
barrierSemanticsDismissible: semanticsDismissible,
|
|
clipDetailsNotifier: _clipDetailsNotifier,
|
|
semanticsOnTapHint: barrierOnTapHint,
|
|
);
|
|
} else {
|
|
return ModalBarrier(
|
|
dismissible: barrierDismissible,
|
|
semanticsLabel: barrierLabel,
|
|
barrierSemanticsDismissible: semanticsDismissible,
|
|
clipDetailsNotifier: _clipDetailsNotifier,
|
|
semanticsOnTapHint: barrierOnTapHint,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<T?> showModalSideSheet<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
Color? backgroundColor,
|
|
String? barrierLabel,
|
|
double? elevation,
|
|
ShapeBorder? shape,
|
|
Clip? clipBehavior,
|
|
BoxConstraints? constraints,
|
|
Color? barrierColor,
|
|
bool isScrollControlled = false,
|
|
double scrollControlDisabledMaxHeightRatio =
|
|
_defaultScrollControlDisabledMaxHeightRatio,
|
|
bool useRootNavigator = false,
|
|
bool isDismissible = true,
|
|
bool useSafeArea = false,
|
|
RouteSettings? routeSettings,
|
|
AnimationController? transitionAnimationController,
|
|
Offset? anchorPoint,
|
|
ImageFilter? filter,
|
|
}) {
|
|
assert(debugCheckHasMediaQuery(context));
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
|
|
final NavigatorState navigator =
|
|
Navigator.of(context, rootNavigator: useRootNavigator);
|
|
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
|
return navigator.push(ModalSideSheetRoute<T>(
|
|
builder: builder,
|
|
filter: filter,
|
|
capturedThemes:
|
|
InheritedTheme.capture(from: context, to: navigator.context),
|
|
isScrollControlled: isScrollControlled,
|
|
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
|
barrierLabel: barrierLabel ?? localizations.scrimLabel,
|
|
barrierOnTapHint:
|
|
localizations.scrimOnTapHint(localizations.bottomSheetLabel),
|
|
backgroundColor: backgroundColor,
|
|
elevation: elevation,
|
|
shape: shape,
|
|
clipBehavior: clipBehavior,
|
|
constraints: constraints,
|
|
isDismissible: isDismissible,
|
|
modalBarrierColor:
|
|
barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor,
|
|
settings: routeSettings,
|
|
transitionAnimationController: transitionAnimationController,
|
|
anchorPoint: anchorPoint,
|
|
useSafeArea: useSafeArea,
|
|
));
|
|
}
|
|
|
|
// class ModalAppBar extends StatelessWidget {
|
|
// final String title;
|
|
//
|
|
// const ModalAppBar({
|
|
// super.key,
|
|
// required this.title,
|
|
// });
|
|
//
|
|
// @override
|
|
// Widget build(BuildContext context) {
|
|
// return AppBar(
|
|
// automaticallyImplyLeading: false,
|
|
// title: Text(title),
|
|
// centerTitle: false,
|
|
// actions: const [
|
|
// SizedBox(
|
|
// height: kToolbarHeight,
|
|
// width: kToolbarHeight,
|
|
// child: CloseButton(),
|
|
// )
|
|
// ],
|
|
// );
|
|
// }
|
|
// }
|