mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
fix(mornye): preserve navbar blur during collapse animations
This commit is contained in:
1 parent
d1eba29a4a
commit
fe30871e36
3 files changed
+323
-159
No files matched your search
@@ -61,7 +61,7 @@ class MornyeChromeController extends ValueNotifier<bool> {
|
||||
|
||||
/// A single mini-player survives the transition, preserving its artwork Hero,
|
||||
/// playback controls and swipe-to-dismiss state as the tabs fold away.
|
||||
class MornyeBottomBar extends ConsumerWidget {
|
||||
class MornyeBottomBar extends ConsumerStatefulWidget {
|
||||
const MornyeBottomBar({
|
||||
super.key,
|
||||
required this.collapsed,
|
||||
@@ -82,7 +82,59 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
final bool blurEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<MornyeBottomBar> createState() => _MornyeBottomBarState();
|
||||
}
|
||||
|
||||
class _MornyeBottomBarState extends ConsumerState<MornyeBottomBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 380),
|
||||
value: widget.collapsed ? 1 : 0,
|
||||
);
|
||||
late final _collapse = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOutCubic,
|
||||
);
|
||||
late final _tabOpacity = ReverseAnimation(_collapse);
|
||||
|
||||
void _updateCollapse() {
|
||||
final target = widget.collapsed ? 1.0 : 0.0;
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_controller.value = target;
|
||||
} else {
|
||||
_controller.animateTo(target);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_updateCollapse();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MornyeBottomBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.collapsed != widget.collapsed) _updateCollapse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_collapse.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collapsed = widget.collapsed;
|
||||
final destinations = widget.destinations;
|
||||
final selectedIndex = widget.selectedIndex;
|
||||
final onSelected = widget.onSelected;
|
||||
final onHome = widget.onHome;
|
||||
final onSearch = widget.onSearch;
|
||||
final blurEnabled = widget.blurEnabled;
|
||||
final hasPlayer = ref.watch(
|
||||
currentMediaItemProvider.select((item) => item.value != null),
|
||||
);
|
||||
@@ -95,14 +147,18 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
);
|
||||
final tabGap = glassTabs ? 0.0 : 8.0;
|
||||
// These contents do not depend on animation progress. Retain their widget
|
||||
// instances so folding only updates size/opacity wrappers each frame.
|
||||
Widget sideSurface() => RepaintBoundary(
|
||||
child: MornyeGlass.navigation(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.navigationOpacity(context),
|
||||
radius: 26,
|
||||
child: const SizedBox.expand(),
|
||||
// instances so folding only updates geometry and foreground opacity. A
|
||||
// backdrop inside a fading layer loses access to the page behind the bar.
|
||||
Widget sideSurface() => ScaleTransition(
|
||||
scale: _collapse,
|
||||
child: RepaintBoundary(
|
||||
child: MornyeGlass.navigation(
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.navigationOpacity(context),
|
||||
radius: 26,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final leadingSurface = sideSurface();
|
||||
@@ -122,6 +178,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
index == destinations.length - 1 ? onSearch() : onSelected(index),
|
||||
blurEnabled: blurEnabled,
|
||||
liquidGlass: liquidGlass,
|
||||
contentOpacity: _tabOpacity,
|
||||
hiddenIconIndices: hideMovingIcons
|
||||
? {leadingIndex, destinations.length - 1}
|
||||
: const {},
|
||||
@@ -158,13 +215,10 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
(constraints.maxWidth - tabInset * 2) /
|
||||
destinations.length *
|
||||
(index + 0.5);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: collapsed ? 1 : 0),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
return AnimatedBuilder(
|
||||
animation: _collapse,
|
||||
builder: (context, _) {
|
||||
final amount = _collapse.value;
|
||||
Widget movingIcon({
|
||||
required bool leading,
|
||||
required Widget surface,
|
||||
@@ -188,9 +242,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Opacity(opacity: amount, child: surface),
|
||||
),
|
||||
Positioned.fill(child: surface),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
@@ -269,10 +321,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
ignoring: amount > 0.5,
|
||||
child: ExcludeSemantics(
|
||||
excluding: amount > 0.5,
|
||||
child: Opacity(
|
||||
opacity: 1 - amount,
|
||||
child: amount == 0 ? fullTabs : foldingTabs,
|
||||
),
|
||||
child: amount == 0 ? fullTabs : foldingTabs,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+147
-135
@@ -612,6 +612,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
required this.blurEnabled,
|
||||
this.liquidGlass = true,
|
||||
this.hiddenIconIndices = const {},
|
||||
this.contentOpacity = const AlwaysStoppedAnimation(1),
|
||||
});
|
||||
|
||||
/// Shader capsule and travelling pill; otherwise the frosted row below.
|
||||
@@ -638,6 +639,10 @@ class MornyeTabBar extends StatelessWidget {
|
||||
// The active and Search icons move independently while the capsule folds.
|
||||
final Set<int> hiddenIconIndices;
|
||||
|
||||
/// Fade icons and labels while the capsule folds. Its backdrop must stay
|
||||
/// outside the fade layer so it can still sample the page during motion.
|
||||
final Animation<double> contentOpacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -673,79 +678,83 @@ class MornyeTabBar extends StatelessWidget {
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
NativeGlassMetrics(
|
||||
child: LiquidGlassTabBar.withImpeller(
|
||||
width: constraints.maxWidth,
|
||||
height: 64,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
|
||||
onChanged: onSelected,
|
||||
style: LiquidGlassTabBar.defaultStyle.copyWith(
|
||||
// The frosted base owns the subtle outline. Disable the
|
||||
// package's default specular rim around the whole capsule.
|
||||
shape: const LiquidGlassShape.continuousRoundedRectangle(
|
||||
cornerRadius: 32,
|
||||
borderWidth: 0,
|
||||
lightIntensity: 0,
|
||||
),
|
||||
// The sibling surface already supplies tint and blur. Keep
|
||||
// the lens clear so the moving pill can refract the icons.
|
||||
appearance: const LiquidGlassAppearance(),
|
||||
refraction: const LiquidGlassRefraction(
|
||||
distortion: 0,
|
||||
chromaticAberration: 0,
|
||||
),
|
||||
),
|
||||
itemStyle: LiquidGlassTabItemStyle(
|
||||
selectedColor: scheme.primary,
|
||||
unselectedColor: scheme.onSurface,
|
||||
iconSize: 25,
|
||||
labelFontSize: 11,
|
||||
),
|
||||
pillStyle: LiquidGlassTabPillStyle(
|
||||
mode: LiquidGlassPillMode.impellerOnly,
|
||||
show: selectedIndex >= 0,
|
||||
color: selectionFill,
|
||||
animated: true,
|
||||
// Keep the moving refractive pill, without stacking the
|
||||
// package's second magnifier lens beneath it.
|
||||
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
for (final (index, destination) in destinations.indexed)
|
||||
LiquidGlassTabBarItem(
|
||||
label: destination.label,
|
||||
iconBuilder: (context, icon) => IconTheme(
|
||||
data: IconThemeData(
|
||||
size: icon.size,
|
||||
color: icon.selected && selectedIndex >= 0
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
FadeTransition(
|
||||
opacity: contentOpacity,
|
||||
child: NativeGlassMetrics(
|
||||
child: LiquidGlassTabBar.withImpeller(
|
||||
width: constraints.maxWidth,
|
||||
height: 64,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
|
||||
onChanged: onSelected,
|
||||
style: LiquidGlassTabBar.defaultStyle.copyWith(
|
||||
// The frosted base owns the subtle outline. Disable the
|
||||
// package's default specular rim around the whole capsule.
|
||||
shape:
|
||||
const LiquidGlassShape.continuousRoundedRectangle(
|
||||
cornerRadius: 32,
|
||||
borderWidth: 0,
|
||||
lightIntensity: 0,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: hiddenIconIndices.contains(index)
|
||||
? 0
|
||||
: 1,
|
||||
child: destination.icon,
|
||||
),
|
||||
),
|
||||
labelBuilder: (context, label) => Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontSize: label.textStyle.fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selectedIndex < 0
|
||||
? scheme.onSurface
|
||||
: label.textStyle.color,
|
||||
),
|
||||
),
|
||||
// The sibling surface already supplies tint and blur. Keep
|
||||
// the lens clear so the moving pill can refract the icons.
|
||||
appearance: const LiquidGlassAppearance(),
|
||||
refraction: const LiquidGlassRefraction(
|
||||
distortion: 0,
|
||||
chromaticAberration: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
itemStyle: LiquidGlassTabItemStyle(
|
||||
selectedColor: scheme.primary,
|
||||
unselectedColor: scheme.onSurface,
|
||||
iconSize: 25,
|
||||
labelFontSize: 11,
|
||||
),
|
||||
pillStyle: LiquidGlassTabPillStyle(
|
||||
mode: LiquidGlassPillMode.impellerOnly,
|
||||
show: selectedIndex >= 0,
|
||||
color: selectionFill,
|
||||
animated: true,
|
||||
// Keep the moving refractive pill, without stacking the
|
||||
// package's second magnifier lens beneath it.
|
||||
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
for (final (index, destination) in destinations.indexed)
|
||||
LiquidGlassTabBarItem(
|
||||
label: destination.label,
|
||||
iconBuilder: (context, icon) => IconTheme(
|
||||
data: IconThemeData(
|
||||
size: icon.size,
|
||||
color: icon.selected && selectedIndex >= 0
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: hiddenIconIndices.contains(index)
|
||||
? 0
|
||||
: 1,
|
||||
child: destination.icon,
|
||||
),
|
||||
),
|
||||
labelBuilder: (context, label) => Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontSize: label.textStyle.fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selectedIndex < 0
|
||||
? scheme.onSurface
|
||||
: label.textStyle.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// With no selected tab the package still needs an internal
|
||||
@@ -784,77 +793,80 @@ class MornyeTabBar extends StatelessWidget {
|
||||
blurEnabled: blurEnabled,
|
||||
strongTint: true,
|
||||
tintOpacity: MornyeTheme.navigationOpacity(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < destinations.length; index++)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
selected: index == selectedIndex,
|
||||
button: true,
|
||||
label: destinations[index].label,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: () => onSelected(index),
|
||||
child: AnimatedContainer(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
constraints: const BoxConstraints(minHeight: 54),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: index == selectedIndex
|
||||
? selectionFill
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 25,
|
||||
color: index == selectedIndex
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
child: FadeTransition(
|
||||
opacity: contentOpacity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < destinations.length; index++)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
selected: index == selectedIndex,
|
||||
button: true,
|
||||
label: destinations[index].label,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: () => onSelected(index),
|
||||
child: AnimatedContainer(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
constraints: const BoxConstraints(minHeight: 54),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: index == selectedIndex
|
||||
? selectionFill
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 25,
|
||||
color: index == selectedIndex
|
||||
? scheme.primary
|
||||
: inactiveIconColor,
|
||||
),
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: Opacity(
|
||||
opacity: hiddenIconIndices.contains(index)
|
||||
? 0
|
||||
: 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
),
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: Opacity(
|
||||
opacity: hiddenIconIndices.contains(index)
|
||||
? 0
|
||||
: 1,
|
||||
child: destinations[index].icon,
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
destinations[index].label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: index == selectedIndex
|
||||
? scheme.primary
|
||||
: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
destinations[index].label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: index == selectedIndex
|
||||
? scheme.primary
|
||||
: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user