mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-07-29 15:00:14 +08:00
777
lib/common/widgets/floating_navigation_bar.dart
Normal file
777
lib/common/widgets/floating_navigation_bar.dart
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
// 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 'package:PiliPlus/utils/extension/theme_ext.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const double _kMaxLabelTextScaleFactor = 1.3;
|
||||||
|
|
||||||
|
const _kNavigationHeight = 64.0;
|
||||||
|
const _kIndicatorHeight = _kNavigationHeight - 2 * _kIndicatorPaddingInt;
|
||||||
|
const _kIndicatorWidth = 86.0;
|
||||||
|
const _kIndicatorPaddingInt = 4.0;
|
||||||
|
const _kIndicatorPadding = EdgeInsets.all(_kIndicatorPaddingInt);
|
||||||
|
const _kBorderRadius = BorderRadius.all(.circular(_kNavigationHeight / 2));
|
||||||
|
const _kNavigationShape = RoundedSuperellipseBorder(
|
||||||
|
borderRadius: _kBorderRadius,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// ref [NavigationBar]
|
||||||
|
class FloatingNavigationBar extends StatelessWidget {
|
||||||
|
// ignore: prefer_const_constructors_in_immutables
|
||||||
|
FloatingNavigationBar({
|
||||||
|
super.key,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 500),
|
||||||
|
this.selectedIndex = 0,
|
||||||
|
required this.destinations,
|
||||||
|
this.onDestinationSelected,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.elevation,
|
||||||
|
this.shadowColor,
|
||||||
|
this.surfaceTintColor,
|
||||||
|
this.indicatorColor,
|
||||||
|
this.indicatorShape,
|
||||||
|
this.labelBehavior,
|
||||||
|
this.overlayColor,
|
||||||
|
this.labelTextStyle,
|
||||||
|
this.labelPadding,
|
||||||
|
this.bottomPadding = 8.0,
|
||||||
|
}) : assert(destinations.length >= 2),
|
||||||
|
assert(0 <= selectedIndex && selectedIndex < destinations.length);
|
||||||
|
|
||||||
|
final Duration animationDuration;
|
||||||
|
final int selectedIndex;
|
||||||
|
final List<Widget> destinations;
|
||||||
|
final ValueChanged<int>? onDestinationSelected;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final double? elevation;
|
||||||
|
final Color? shadowColor;
|
||||||
|
final Color? surfaceTintColor;
|
||||||
|
final Color? indicatorColor;
|
||||||
|
final ShapeBorder? indicatorShape;
|
||||||
|
final NavigationDestinationLabelBehavior? labelBehavior;
|
||||||
|
final WidgetStateProperty<Color?>? overlayColor;
|
||||||
|
final WidgetStateProperty<TextStyle?>? labelTextStyle;
|
||||||
|
final EdgeInsetsGeometry? labelPadding;
|
||||||
|
final double bottomPadding;
|
||||||
|
|
||||||
|
VoidCallback _handleTap(int index) {
|
||||||
|
return onDestinationSelected != null
|
||||||
|
? () => onDestinationSelected!(index)
|
||||||
|
: () {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final defaults = _NavigationBarDefaultsM3(context);
|
||||||
|
|
||||||
|
final navigationBarTheme = NavigationBarTheme.of(context);
|
||||||
|
final effectiveLabelBehavior =
|
||||||
|
labelBehavior ??
|
||||||
|
navigationBarTheme.labelBehavior ??
|
||||||
|
defaults.labelBehavior!;
|
||||||
|
|
||||||
|
final padding = MediaQuery.viewPaddingOf(context);
|
||||||
|
|
||||||
|
return UnconstrainedBox(
|
||||||
|
child: Padding(
|
||||||
|
padding: .fromLTRB(
|
||||||
|
padding.left,
|
||||||
|
0,
|
||||||
|
padding.right,
|
||||||
|
bottomPadding + padding.bottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: _kNavigationHeight,
|
||||||
|
width: destinations.length * _kIndicatorWidth,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: ShapeDecoration(
|
||||||
|
color: ElevationOverlay.applySurfaceTint(
|
||||||
|
backgroundColor ??
|
||||||
|
navigationBarTheme.backgroundColor ??
|
||||||
|
defaults.backgroundColor!,
|
||||||
|
surfaceTintColor ??
|
||||||
|
navigationBarTheme.surfaceTintColor ??
|
||||||
|
defaults.surfaceTintColor,
|
||||||
|
elevation ??
|
||||||
|
navigationBarTheme.elevation ??
|
||||||
|
defaults.elevation!,
|
||||||
|
),
|
||||||
|
shape: RoundedSuperellipseBorder(
|
||||||
|
side: defaults.borderSide,
|
||||||
|
borderRadius: _kBorderRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: _kIndicatorPadding,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: .stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
for (int i = 0; i < destinations.length; i++)
|
||||||
|
Expanded(
|
||||||
|
child: _SelectableAnimatedBuilder(
|
||||||
|
duration: animationDuration,
|
||||||
|
isSelected: i == selectedIndex,
|
||||||
|
builder: (context, animation) {
|
||||||
|
return _NavigationDestinationInfo(
|
||||||
|
index: i,
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
totalNumberOfDestinations: destinations.length,
|
||||||
|
selectedAnimation: animation,
|
||||||
|
labelBehavior: effectiveLabelBehavior,
|
||||||
|
indicatorColor: indicatorColor,
|
||||||
|
indicatorShape: indicatorShape,
|
||||||
|
overlayColor: overlayColor,
|
||||||
|
onTap: _handleTap(i),
|
||||||
|
labelTextStyle: labelTextStyle,
|
||||||
|
labelPadding: labelPadding,
|
||||||
|
child: destinations[i],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FloatingNavigationDestination extends StatelessWidget {
|
||||||
|
const FloatingNavigationDestination({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
this.selectedIcon,
|
||||||
|
required this.label,
|
||||||
|
this.tooltip,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget icon;
|
||||||
|
|
||||||
|
final Widget? selectedIcon;
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final info = _NavigationDestinationInfo.of(context);
|
||||||
|
const selectedState = <WidgetState>{WidgetState.selected};
|
||||||
|
const unselectedState = <WidgetState>{};
|
||||||
|
const disabledState = <WidgetState>{WidgetState.disabled};
|
||||||
|
|
||||||
|
final navigationBarTheme = NavigationBarTheme.of(context);
|
||||||
|
final defaults = _NavigationBarDefaultsM3(context);
|
||||||
|
final animation = info.selectedAnimation;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
alignment: .center,
|
||||||
|
clipBehavior: .none,
|
||||||
|
children: [
|
||||||
|
NavigationIndicator(
|
||||||
|
animation: animation,
|
||||||
|
color:
|
||||||
|
info.indicatorColor ??
|
||||||
|
navigationBarTheme.indicatorColor ??
|
||||||
|
defaults.indicatorColor!,
|
||||||
|
),
|
||||||
|
_NavigationDestinationBuilder(
|
||||||
|
label: label,
|
||||||
|
tooltip: tooltip,
|
||||||
|
enabled: enabled,
|
||||||
|
buildIcon: (context) {
|
||||||
|
final IconThemeData selectedIconTheme =
|
||||||
|
navigationBarTheme.iconTheme?.resolve(selectedState) ??
|
||||||
|
defaults.iconTheme!.resolve(selectedState)!;
|
||||||
|
final IconThemeData unselectedIconTheme =
|
||||||
|
navigationBarTheme.iconTheme?.resolve(unselectedState) ??
|
||||||
|
defaults.iconTheme!.resolve(unselectedState)!;
|
||||||
|
final IconThemeData disabledIconTheme =
|
||||||
|
navigationBarTheme.iconTheme?.resolve(disabledState) ??
|
||||||
|
defaults.iconTheme!.resolve(disabledState)!;
|
||||||
|
|
||||||
|
final Widget selectedIconWidget = IconTheme.merge(
|
||||||
|
data: enabled ? selectedIconTheme : disabledIconTheme,
|
||||||
|
child: selectedIcon ?? icon,
|
||||||
|
);
|
||||||
|
final Widget unselectedIconWidget = IconTheme.merge(
|
||||||
|
data: enabled ? unselectedIconTheme : disabledIconTheme,
|
||||||
|
child: icon,
|
||||||
|
);
|
||||||
|
return _StatusTransitionWidgetBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return animation.isForwardOrCompleted
|
||||||
|
? selectedIconWidget
|
||||||
|
: unselectedIconWidget;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
buildLabel: (context) {
|
||||||
|
final TextStyle? effectiveSelectedLabelTextStyle =
|
||||||
|
info.labelTextStyle?.resolve(selectedState) ??
|
||||||
|
navigationBarTheme.labelTextStyle?.resolve(selectedState) ??
|
||||||
|
defaults.labelTextStyle!.resolve(selectedState);
|
||||||
|
final TextStyle? effectiveUnselectedLabelTextStyle =
|
||||||
|
info.labelTextStyle?.resolve(unselectedState) ??
|
||||||
|
navigationBarTheme.labelTextStyle?.resolve(unselectedState) ??
|
||||||
|
defaults.labelTextStyle!.resolve(unselectedState);
|
||||||
|
final TextStyle? effectiveDisabledLabelTextStyle =
|
||||||
|
info.labelTextStyle?.resolve(disabledState) ??
|
||||||
|
navigationBarTheme.labelTextStyle?.resolve(disabledState) ??
|
||||||
|
defaults.labelTextStyle!.resolve(disabledState);
|
||||||
|
final EdgeInsetsGeometry labelPadding =
|
||||||
|
info.labelPadding ??
|
||||||
|
navigationBarTheme.labelPadding ??
|
||||||
|
defaults.labelPadding!;
|
||||||
|
|
||||||
|
final textStyle = enabled
|
||||||
|
? animation.isForwardOrCompleted
|
||||||
|
? effectiveSelectedLabelTextStyle
|
||||||
|
: effectiveUnselectedLabelTextStyle
|
||||||
|
: effectiveDisabledLabelTextStyle;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: labelPadding,
|
||||||
|
child: MediaQuery.withClampedTextScaling(
|
||||||
|
maxScaleFactor: _kMaxLabelTextScaleFactor,
|
||||||
|
child: Text(label, style: textStyle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationDestinationBuilder extends StatefulWidget {
|
||||||
|
const _NavigationDestinationBuilder({
|
||||||
|
required this.buildIcon,
|
||||||
|
required this.buildLabel,
|
||||||
|
required this.label,
|
||||||
|
this.tooltip,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WidgetBuilder buildIcon;
|
||||||
|
|
||||||
|
final WidgetBuilder buildLabel;
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NavigationDestinationBuilder> createState() =>
|
||||||
|
_NavigationDestinationBuilderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationDestinationBuilderState
|
||||||
|
extends State<_NavigationDestinationBuilder> {
|
||||||
|
final GlobalKey iconKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final info = _NavigationDestinationInfo.of(context);
|
||||||
|
|
||||||
|
final child = GestureDetector(
|
||||||
|
behavior: .opaque,
|
||||||
|
onTap: widget.enabled ? info.onTap : null,
|
||||||
|
child: _NavigationBarDestinationLayout(
|
||||||
|
icon: widget.buildIcon(context),
|
||||||
|
iconKey: iconKey,
|
||||||
|
label: widget.buildLabel(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (info.labelBehavior == .alwaysShow) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return _NavigationBarDestinationTooltip(
|
||||||
|
message: widget.tooltip ?? widget.label,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationDestinationInfo extends InheritedWidget {
|
||||||
|
const _NavigationDestinationInfo({
|
||||||
|
required this.index,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.totalNumberOfDestinations,
|
||||||
|
required this.selectedAnimation,
|
||||||
|
required this.labelBehavior,
|
||||||
|
required this.indicatorColor,
|
||||||
|
required this.indicatorShape,
|
||||||
|
required this.overlayColor,
|
||||||
|
required this.onTap,
|
||||||
|
this.labelTextStyle,
|
||||||
|
this.labelPadding,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
final int selectedIndex;
|
||||||
|
|
||||||
|
final int totalNumberOfDestinations;
|
||||||
|
|
||||||
|
final Animation<double> selectedAnimation;
|
||||||
|
|
||||||
|
final NavigationDestinationLabelBehavior labelBehavior;
|
||||||
|
|
||||||
|
final Color? indicatorColor;
|
||||||
|
|
||||||
|
final ShapeBorder? indicatorShape;
|
||||||
|
|
||||||
|
final WidgetStateProperty<Color?>? overlayColor;
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
final WidgetStateProperty<TextStyle?>? labelTextStyle;
|
||||||
|
|
||||||
|
final EdgeInsetsGeometry? labelPadding;
|
||||||
|
|
||||||
|
static _NavigationDestinationInfo of(BuildContext context) {
|
||||||
|
final _NavigationDestinationInfo? result = context
|
||||||
|
.dependOnInheritedWidgetOfExactType<_NavigationDestinationInfo>();
|
||||||
|
assert(
|
||||||
|
result != null,
|
||||||
|
'Navigation destinations need a _NavigationDestinationInfo parent, '
|
||||||
|
'which is usually provided by NavigationBar.',
|
||||||
|
);
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_NavigationDestinationInfo oldWidget) {
|
||||||
|
return index != oldWidget.index ||
|
||||||
|
totalNumberOfDestinations != oldWidget.totalNumberOfDestinations ||
|
||||||
|
selectedAnimation != oldWidget.selectedAnimation ||
|
||||||
|
labelBehavior != oldWidget.labelBehavior ||
|
||||||
|
onTap != oldWidget.onTap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NavigationIndicator extends StatelessWidget {
|
||||||
|
const NavigationIndicator({
|
||||||
|
super.key,
|
||||||
|
required this.animation,
|
||||||
|
this.color,
|
||||||
|
this.width = _kIndicatorWidth,
|
||||||
|
this.height = _kIndicatorHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Animation<double> animation;
|
||||||
|
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
static final _anim = Tween<double>(
|
||||||
|
begin: .5,
|
||||||
|
end: 1.0,
|
||||||
|
).chain(CurveTween(curve: Curves.easeInOutCubicEmphasized));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final double scale = animation.isDismissed
|
||||||
|
? 0.0
|
||||||
|
: _anim.evaluate(animation);
|
||||||
|
|
||||||
|
return Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.diagonal3Values(scale, 1.0, 1.0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
child: _StatusTransitionWidgetBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return _SelectableAnimatedBuilder(
|
||||||
|
isSelected: animation.isForwardOrCompleted,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
alwaysDoFullAnimation: true,
|
||||||
|
builder: (context, fadeAnimation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: ShapeDecoration(
|
||||||
|
shape: _kNavigationShape,
|
||||||
|
color: color ?? Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
child: const SizedBox(
|
||||||
|
width: _kIndicatorWidth,
|
||||||
|
height: _kIndicatorHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationBarDestinationLayout extends StatelessWidget {
|
||||||
|
const _NavigationBarDestinationLayout({
|
||||||
|
required this.icon,
|
||||||
|
required this.iconKey,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget icon;
|
||||||
|
|
||||||
|
final GlobalKey iconKey;
|
||||||
|
|
||||||
|
final Widget label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _DestinationLayoutAnimationBuilder(
|
||||||
|
builder: (context, animation) {
|
||||||
|
return CustomMultiChildLayout(
|
||||||
|
delegate: _NavigationDestinationLayoutDelegate(animation: animation),
|
||||||
|
children: <Widget>[
|
||||||
|
LayoutId(
|
||||||
|
id: _NavigationDestinationLayoutDelegate.iconId,
|
||||||
|
child: KeyedSubtree(key: iconKey, child: icon),
|
||||||
|
),
|
||||||
|
LayoutId(
|
||||||
|
id: _NavigationDestinationLayoutDelegate.labelId,
|
||||||
|
child: FadeTransition(
|
||||||
|
alwaysIncludeSemantics: true,
|
||||||
|
opacity: animation,
|
||||||
|
child: label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DestinationLayoutAnimationBuilder extends StatelessWidget {
|
||||||
|
const _DestinationLayoutAnimationBuilder({required this.builder});
|
||||||
|
|
||||||
|
final Widget Function(BuildContext, Animation<double>) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final info = _NavigationDestinationInfo.of(context);
|
||||||
|
switch (info.labelBehavior) {
|
||||||
|
case NavigationDestinationLabelBehavior.alwaysShow:
|
||||||
|
return builder(context, kAlwaysCompleteAnimation);
|
||||||
|
case NavigationDestinationLabelBehavior.alwaysHide:
|
||||||
|
return builder(context, kAlwaysDismissedAnimation);
|
||||||
|
case NavigationDestinationLabelBehavior.onlyShowSelected:
|
||||||
|
return _CurvedAnimationBuilder(
|
||||||
|
animation: info.selectedAnimation,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
reverseCurve: Curves.easeInOutCubicEmphasized.flipped,
|
||||||
|
builder: builder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationBarDestinationTooltip extends StatelessWidget {
|
||||||
|
const _NavigationBarDestinationTooltip({
|
||||||
|
required this.message,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: message,
|
||||||
|
verticalOffset: 34,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
preferBelow: false,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationDestinationLayoutDelegate extends MultiChildLayoutDelegate {
|
||||||
|
_NavigationDestinationLayoutDelegate({required this.animation})
|
||||||
|
: super(relayout: animation);
|
||||||
|
|
||||||
|
final Animation<double> animation;
|
||||||
|
|
||||||
|
static const int iconId = 1;
|
||||||
|
|
||||||
|
static const int labelId = 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout(Size size) {
|
||||||
|
double halfWidth(Size size) => size.width / 2;
|
||||||
|
double halfHeight(Size size) => size.height / 2;
|
||||||
|
|
||||||
|
final Size iconSize = layoutChild(iconId, BoxConstraints.loose(size));
|
||||||
|
final Size labelSize = layoutChild(labelId, BoxConstraints.loose(size));
|
||||||
|
|
||||||
|
final double yPositionOffset = Tween<double>(
|
||||||
|
begin: halfHeight(iconSize),
|
||||||
|
|
||||||
|
end: halfHeight(iconSize) + halfHeight(labelSize),
|
||||||
|
).transform(animation.value);
|
||||||
|
final double iconYPosition = halfHeight(size) - yPositionOffset;
|
||||||
|
|
||||||
|
positionChild(
|
||||||
|
iconId,
|
||||||
|
Offset(
|
||||||
|
halfWidth(size) - halfWidth(iconSize),
|
||||||
|
iconYPosition,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
positionChild(
|
||||||
|
labelId,
|
||||||
|
Offset(
|
||||||
|
halfWidth(size) - halfWidth(labelSize),
|
||||||
|
|
||||||
|
iconYPosition + iconSize.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRelayout(_NavigationDestinationLayoutDelegate oldDelegate) {
|
||||||
|
return oldDelegate.animation != animation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusTransitionWidgetBuilder extends StatusTransitionWidget {
|
||||||
|
const _StatusTransitionWidgetBuilder({
|
||||||
|
required super.animation,
|
||||||
|
required this.builder,
|
||||||
|
// ignore: unused_element_parameter
|
||||||
|
this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TransitionBuilder builder;
|
||||||
|
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => builder(context, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableAnimatedBuilder extends StatefulWidget {
|
||||||
|
const _SelectableAnimatedBuilder({
|
||||||
|
required this.isSelected,
|
||||||
|
this.duration = const Duration(milliseconds: 200),
|
||||||
|
this.alwaysDoFullAnimation = false,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
final bool alwaysDoFullAnimation;
|
||||||
|
|
||||||
|
final Widget Function(BuildContext, Animation<double>) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
_SelectableAnimatedBuilderState createState() =>
|
||||||
|
_SelectableAnimatedBuilderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableAnimatedBuilderState extends State<_SelectableAnimatedBuilder>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(vsync: this);
|
||||||
|
_controller.duration = widget.duration;
|
||||||
|
_controller.value = widget.isSelected ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_SelectableAnimatedBuilder oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.duration != widget.duration) {
|
||||||
|
_controller.duration = widget.duration;
|
||||||
|
}
|
||||||
|
if (oldWidget.isSelected != widget.isSelected) {
|
||||||
|
if (widget.isSelected) {
|
||||||
|
_controller.forward(from: widget.alwaysDoFullAnimation ? 0 : null);
|
||||||
|
} else {
|
||||||
|
_controller.reverse(from: widget.alwaysDoFullAnimation ? 1 : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.builder(context, _controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurvedAnimationBuilder extends StatefulWidget {
|
||||||
|
const _CurvedAnimationBuilder({
|
||||||
|
required this.animation,
|
||||||
|
required this.curve,
|
||||||
|
required this.reverseCurve,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Curve curve;
|
||||||
|
final Curve reverseCurve;
|
||||||
|
final Widget Function(BuildContext, Animation<double>) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
_CurvedAnimationBuilderState createState() => _CurvedAnimationBuilderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurvedAnimationBuilderState extends State<_CurvedAnimationBuilder> {
|
||||||
|
late AnimationStatus _animationDirection;
|
||||||
|
AnimationStatus? _preservedDirection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationDirection = widget.animation.status;
|
||||||
|
_updateStatus(widget.animation.status);
|
||||||
|
widget.animation.addStatusListener(_updateStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.animation.removeStatusListener(_updateStatus);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateStatus(AnimationStatus status) {
|
||||||
|
if (_animationDirection != status) {
|
||||||
|
setState(() {
|
||||||
|
_animationDirection = status;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case AnimationStatus.forward || AnimationStatus.reverse
|
||||||
|
when _preservedDirection != null:
|
||||||
|
break;
|
||||||
|
case AnimationStatus.forward || AnimationStatus.reverse:
|
||||||
|
setState(() {
|
||||||
|
_preservedDirection = status;
|
||||||
|
});
|
||||||
|
case AnimationStatus.completed || AnimationStatus.dismissed:
|
||||||
|
setState(() {
|
||||||
|
_preservedDirection = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final shouldUseForwardCurve =
|
||||||
|
(_preservedDirection ?? _animationDirection) != AnimationStatus.reverse;
|
||||||
|
|
||||||
|
final Animation<double> curvedAnimation = CurveTween(
|
||||||
|
curve: shouldUseForwardCurve ? widget.curve : widget.reverseCurve,
|
||||||
|
).animate(widget.animation);
|
||||||
|
|
||||||
|
return widget.builder(context, curvedAnimation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _indicatorDark = Color(0x15FFFFFF);
|
||||||
|
const _indicatorLight = Color(0x10000000);
|
||||||
|
|
||||||
|
class _NavigationBarDefaultsM3 extends NavigationBarThemeData {
|
||||||
|
_NavigationBarDefaultsM3(this.context)
|
||||||
|
: super(
|
||||||
|
height: _kNavigationHeight,
|
||||||
|
elevation: 3.0,
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
|
);
|
||||||
|
|
||||||
|
final BuildContext context;
|
||||||
|
late final _colors = Theme.of(context).colorScheme;
|
||||||
|
late final _textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
BorderSide get borderSide => _colors.brightness.isDark
|
||||||
|
? const BorderSide(color: Color(0x08FFFFFF))
|
||||||
|
: const BorderSide(color: Color(0x08000000));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color? get backgroundColor => _colors.surfaceContainer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color? get shadowColor => Colors.transparent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color? get surfaceTintColor => Colors.transparent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
WidgetStateProperty<IconThemeData?>? get iconTheme {
|
||||||
|
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||||
|
return IconThemeData(
|
||||||
|
size: 24.0,
|
||||||
|
color: states.contains(WidgetState.disabled)
|
||||||
|
? _colors.onSurfaceVariant.withValues(alpha: 0.38)
|
||||||
|
: states.contains(WidgetState.selected)
|
||||||
|
? _colors.onSecondaryContainer
|
||||||
|
: _colors.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color? get indicatorColor =>
|
||||||
|
_colors.brightness.isDark ? _indicatorDark : _indicatorLight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ShapeBorder? get indicatorShape => const StadiumBorder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
WidgetStateProperty<TextStyle?>? get labelTextStyle {
|
||||||
|
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||||
|
final TextStyle style = _textTheme.labelMedium!;
|
||||||
|
return style.apply(
|
||||||
|
color: states.contains(WidgetState.disabled)
|
||||||
|
? _colors.onSurfaceVariant.withValues(alpha: 0.38)
|
||||||
|
: states.contains(WidgetState.selected)
|
||||||
|
? _colors.onSurface
|
||||||
|
: _colors.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
EdgeInsetsGeometry? get labelPadding => const EdgeInsets.only(top: 2);
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import 'package:flutter/material.dart';
|
|||||||
enum NavigationBarType implements EnumWithLabel {
|
enum NavigationBarType implements EnumWithLabel {
|
||||||
home(
|
home(
|
||||||
'首页',
|
'首页',
|
||||||
Icon(Icons.home_outlined, size: 23),
|
Icon(Icons.home_outlined, size: 24),
|
||||||
Icon(Icons.home, size: 21),
|
Icon(Icons.home, size: 24),
|
||||||
HomePage(),
|
HomePage(),
|
||||||
),
|
),
|
||||||
dynamics(
|
dynamics(
|
||||||
@@ -19,10 +19,10 @@ enum NavigationBarType implements EnumWithLabel {
|
|||||||
),
|
),
|
||||||
mine(
|
mine(
|
||||||
'我的',
|
'我的',
|
||||||
Icon(Icons.person_outline, size: 21),
|
Icon(Icons.person_outline, size: 24),
|
||||||
Icon(Icons.person, size: 21),
|
Icon(Icons.person, size: 24),
|
||||||
MinePage(),
|
MinePage(),
|
||||||
)
|
),
|
||||||
;
|
;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:PiliPlus/common/widgets/avatars.dart';
|
import 'package:PiliPlus/common/widgets/avatars.dart';
|
||||||
import 'package:PiliPlus/common/widgets/badge.dart';
|
import 'package:PiliPlus/common/widgets/badge.dart';
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class MainController extends GetxController
|
|||||||
late int lastCheckUnreadAt = 0;
|
late int lastCheckUnreadAt = 0;
|
||||||
|
|
||||||
final enableMYBar = Pref.enableMYBar;
|
final enableMYBar = Pref.enableMYBar;
|
||||||
|
final floatingNavBar = Pref.floatingNavBar;
|
||||||
final useSideBar = Pref.useSideBar;
|
final useSideBar = Pref.useSideBar;
|
||||||
final mainTabBarView = Pref.mainTabBarView;
|
final mainTabBarView = Pref.mainTabBarView;
|
||||||
late final optTabletNav = Pref.optTabletNav;
|
late final optTabletNav = Pref.optTabletNav;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:PiliPlus/common/assets.dart';
|
import 'package:PiliPlus/common/assets.dart';
|
||||||
import 'package:PiliPlus/common/constants.dart';
|
import 'package:PiliPlus/common/constants.dart';
|
||||||
import 'package:PiliPlus/common/style.dart';
|
import 'package:PiliPlus/common/style.dart';
|
||||||
|
import 'package:PiliPlus/common/widgets/floating_navigation_bar.dart';
|
||||||
import 'package:PiliPlus/common/widgets/flutter/pop_scope.dart';
|
import 'package:PiliPlus/common/widgets/flutter/pop_scope.dart';
|
||||||
import 'package:PiliPlus/common/widgets/flutter/tabs.dart';
|
import 'package:PiliPlus/common/widgets/flutter/tabs.dart';
|
||||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||||
@@ -270,67 +271,88 @@ class _MainAppState extends PopScopeState<MainApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? get _bottomNav {
|
Widget? get _bottomNav {
|
||||||
Widget? bottomNav = _mainController.navigationBars.length > 1
|
Widget? bottomNav;
|
||||||
? _mainController.enableMYBar
|
if (_mainController.navigationBars.length > 1) {
|
||||||
? Obx(
|
if (_mainController.floatingNavBar) {
|
||||||
() => NavigationBar(
|
bottomNav = Obx(
|
||||||
maintainBottomViewPadding: true,
|
() => FloatingNavigationBar(
|
||||||
onDestinationSelected: _mainController.setIndex,
|
onDestinationSelected: _mainController.setIndex,
|
||||||
selectedIndex: _mainController.selectedIndex.value,
|
selectedIndex: _mainController.selectedIndex.value,
|
||||||
destinations: _mainController.navigationBars
|
destinations: _mainController.navigationBars
|
||||||
.map(
|
.map(
|
||||||
(e) => NavigationDestination(
|
(e) => FloatingNavigationDestination(
|
||||||
label: e.label,
|
label: e.label,
|
||||||
icon: _buildIcon(type: e),
|
icon: _buildIcon(type: e),
|
||||||
selectedIcon: _buildIcon(type: e, selected: true),
|
selectedIcon: _buildIcon(type: e, selected: true),
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Obx(
|
.toList(),
|
||||||
() => BottomNavigationBar(
|
),
|
||||||
currentIndex: _mainController.selectedIndex.value,
|
);
|
||||||
onTap: _mainController.setIndex,
|
} else if (_mainController.enableMYBar) {
|
||||||
iconSize: 16,
|
bottomNav = Obx(
|
||||||
selectedFontSize: 12,
|
() => NavigationBar(
|
||||||
unselectedFontSize: 12,
|
maintainBottomViewPadding: true,
|
||||||
type: .fixed,
|
onDestinationSelected: _mainController.setIndex,
|
||||||
items: _mainController.navigationBars
|
selectedIndex: _mainController.selectedIndex.value,
|
||||||
.map(
|
destinations: _mainController.navigationBars
|
||||||
(e) => BottomNavigationBarItem(
|
.map(
|
||||||
label: e.label,
|
(e) => NavigationDestination(
|
||||||
icon: _buildIcon(type: e),
|
label: e.label,
|
||||||
activeIcon: _buildIcon(type: e, selected: true),
|
icon: _buildIcon(type: e),
|
||||||
),
|
selectedIcon: _buildIcon(type: e, selected: true),
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null;
|
.toList(),
|
||||||
if (bottomNav != null && _mainController.hideBottomBar) {
|
),
|
||||||
if (_mainController.barOffset case final barOffset?) {
|
);
|
||||||
return Obx(
|
} else {
|
||||||
() => FractionalTranslation(
|
bottomNav = Obx(
|
||||||
translation: Offset(
|
() => BottomNavigationBar(
|
||||||
0.0,
|
currentIndex: _mainController.selectedIndex.value,
|
||||||
barOffset.value / Style.topBarHeight,
|
onTap: _mainController.setIndex,
|
||||||
),
|
iconSize: 16,
|
||||||
child: bottomNav,
|
selectedFontSize: 12,
|
||||||
|
unselectedFontSize: 12,
|
||||||
|
type: .fixed,
|
||||||
|
items: _mainController.navigationBars
|
||||||
|
.map(
|
||||||
|
(e) => BottomNavigationBarItem(
|
||||||
|
label: e.label,
|
||||||
|
icon: _buildIcon(type: e),
|
||||||
|
activeIcon: _buildIcon(type: e, selected: true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_mainController.showBottomBar case final showBottomBar?) {
|
|
||||||
return Obx(
|
if (_mainController.hideBottomBar) {
|
||||||
() => AnimatedSlide(
|
if (_mainController.barOffset case final barOffset?) {
|
||||||
curve: Curves.easeInOutCubicEmphasized,
|
return Obx(
|
||||||
duration: const Duration(milliseconds: 500),
|
() => FractionalTranslation(
|
||||||
offset: Offset(0, showBottomBar.value ? 0 : 1),
|
translation: Offset(
|
||||||
child: bottomNav,
|
0.0,
|
||||||
),
|
barOffset.value / Style.topBarHeight,
|
||||||
);
|
),
|
||||||
|
child: bottomNav,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_mainController.showBottomBar case final showBottomBar?) {
|
||||||
|
return Obx(
|
||||||
|
() => AnimatedSlide(
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
offset: Offset(0, showBottomBar.value ? 0 : 1),
|
||||||
|
child: bottomNav,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bottomNav;
|
return bottomNav;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ List<SettingsModel> get styleSettings => [
|
|||||||
),
|
),
|
||||||
const SwitchModel(
|
const SwitchModel(
|
||||||
title: '优化平板导航栏',
|
title: '优化平板导航栏',
|
||||||
leading: Icon(MdiIcons.soundbar),
|
leading: Icon(Icons.auto_fix_high),
|
||||||
setKey: SettingBoxKey.optTabletNav,
|
setKey: SettingBoxKey.optTabletNav,
|
||||||
defaultVal: true,
|
defaultVal: true,
|
||||||
needReboot: true,
|
needReboot: true,
|
||||||
@@ -119,6 +119,13 @@ List<SettingsModel> get styleSettings => [
|
|||||||
defaultVal: true,
|
defaultVal: true,
|
||||||
needReboot: true,
|
needReboot: true,
|
||||||
),
|
),
|
||||||
|
const SwitchModel(
|
||||||
|
title: '悬浮底栏',
|
||||||
|
leading: Icon(MdiIcons.soundbar),
|
||||||
|
setKey: SettingBoxKey.floatingNavBar,
|
||||||
|
defaultVal: false,
|
||||||
|
needReboot: true,
|
||||||
|
),
|
||||||
NormalModel(
|
NormalModel(
|
||||||
leading: const Icon(Icons.calendar_view_week_outlined),
|
leading: const Icon(Icons.calendar_view_week_outlined),
|
||||||
title: '列表宽度(dp)限制',
|
title: '列表宽度(dp)限制',
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ abstract final class SettingBoxKey {
|
|||||||
followOrderType = 'followOrderType',
|
followOrderType = 'followOrderType',
|
||||||
enableImgMenu = 'enableImgMenu',
|
enableImgMenu = 'enableImgMenu',
|
||||||
showDynDispute = 'showDynDispute',
|
showDynDispute = 'showDynDispute',
|
||||||
touchSlopH = 'touchSlopH';
|
touchSlopH = 'touchSlopH',
|
||||||
|
floatingNavBar = 'floatingNavBar';
|
||||||
|
|
||||||
static const String minimizeOnExit = 'minimizeOnExit',
|
static const String minimizeOnExit = 'minimizeOnExit',
|
||||||
windowSize = 'windowSize',
|
windowSize = 'windowSize',
|
||||||
|
|||||||
@@ -966,4 +966,7 @@ abstract final class Pref {
|
|||||||
|
|
||||||
static bool get saveReply =>
|
static bool get saveReply =>
|
||||||
_setting.get(SettingBoxKey.saveReply, defaultValue: true);
|
_setting.get(SettingBoxKey.saveReply, defaultValue: true);
|
||||||
|
|
||||||
|
static bool get floatingNavBar =>
|
||||||
|
_setting.get(SettingBoxKey.floatingNavBar, defaultValue: false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user