fix(glass): swell navbar glass without resizing icons

This commit is contained in:
zarzet committed 2026-10-05 22:29:46 +07:00
1 parent a56f206c43
commit 224961d68d
4 files changed
+111 -54

No files matched your search

+67 -2
View File
@@ -92,7 +92,10 @@ void main() {
return builder.animation as CurvedAnimation;
}
Future<void> capture(String name) async {
Future<void> capture(
String name, {
Map<String, Object?> details = const {},
}) async {
await tester.pump();
// Android can acquire the previously displayed image before the new
// Flutter frame reaches its image view. Prime that view, then capture the
@@ -122,6 +125,7 @@ void main() {
// ignore: invalid_use_of_protected_member
visit(tester.binding.renderViews.first.layer!);
poses[name] = {
...details,
'collapseProgress': fold().value,
'controllerProgress': fold().parent.value,
'shaderBackdrops': histogram['_ImpellerShaderBackdropLayer'] ?? 0,
@@ -240,6 +244,41 @@ void main() {
);
final home = tab('Home');
final search = tab('Search');
Rect capsule() => tester.getRect(
find.descendant(
of: find.byType(MornyeTabBar),
matching: find.byType(MornyeGlass),
),
);
Map<String, Rect> foreground() => {
for (final entry in const {
'Home': Icons.home,
'Library': Icons.music_note,
'Repo': Icons.grid_view,
'Search': Icons.search,
}.entries) ...{
'${entry.key}_icon': tester.getRect(
find.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(entry.value),
),
),
'${entry.key}_label': tester.getRect(
find.descendant(
of: find.byType(MornyeTabBar),
matching: find.text(entry.key),
),
),
},
};
List<double> bounds(Rect rect) => [
rect.left,
rect.top,
rect.width,
rect.height,
];
final restingCapsule = capsule();
final restingForeground = foreground();
final touch = await tester.startGesture(home);
try {
await wait(320);
@@ -257,11 +296,37 @@ void main() {
await wait(100);
}
expect(tester.widget<MornyeLiquidBackdrop>(lens).progress, 1);
await capture('${prefix}_held_halfway');
final heldCapsule = capsule();
expect(heldCapsule.width, closeTo(restingCapsule.width * 1.025, 0.01));
expect(heldCapsule.height, closeTo(restingCapsule.height * 1.06, 0.01));
expect(foreground(), restingForeground);
final glass = tester.widget<MornyeLiquidBackdrop>(
find.descendant(
of: find.byType(MornyeTabBar),
matching: find.byWidgetPredicate(
(widget) => widget is MornyeLiquidBackdrop && !widget.interaction,
),
),
);
expect(glass.refractionDepth, closeTo(0.60, 0.001));
await capture(
'${prefix}_held_halfway',
details: {
'restingCapsule': bounds(restingCapsule),
'heldCapsule': bounds(heldCapsule),
'fixedForegroundBounds': {
for (final entry in restingForeground.entries)
entry.key: bounds(entry.value),
},
'heldRefractionDepth': glass.refractionDepth,
},
);
} finally {
await touch.cancel();
}
await wait(300);
expect(capsule(), restingCapsule);
expect(foreground(), restingForeground);
expect(tester.state(find.byType(MiniPlayer)), same(player));
expect(tester.takeException(), isNull);
}
+28 -20
View File
@@ -120,6 +120,7 @@ class MornyeGlass extends ConsumerWidget {
this.tintOpacity,
this.tintColor,
this.highlightBorder = false,
this.refractionDepth = 0.30,
}) : firstInGroup = true,
lastInGroup = true,
samplesBackdrop = true;
@@ -136,6 +137,7 @@ class MornyeGlass extends ConsumerWidget {
this.tintColor,
this.samplesBackdrop = true,
this.highlightBorder = false,
this.refractionDepth = 0.30,
});
// Quantized and shared across surfaces. Avoid composed color filters and
@@ -157,6 +159,7 @@ class MornyeGlass extends ConsumerWidget {
/// Paint the edge above the fill so tinted popovers retain a visible rim.
final bool highlightBorder;
final double refractionDepth;
/// Plain-page surfaces keep their tint without filtering a uniform backdrop.
final bool samplesBackdrop;
@@ -282,6 +285,7 @@ class MornyeGlass extends ConsumerWidget {
borderRadius: shape,
clarity: clarity,
blurSigma: _blurSigmas[filterIndex],
refractionDepth: refractionDepth,
child: surface,
)
: sampleBackdrop
@@ -511,8 +515,8 @@ class MornyeTabBar extends StatelessWidget {
blurEnabled: blurEnabled,
liquidGlass: liquidGlass,
);
final content = FadeTransition(
opacity: contentOpacity,
return Padding(
padding: EdgeInsets.symmetric(vertical: floating ? 8 : 0),
child: MornyeSelectionPill(
selectionColor: scheme.primary,
maskItemForeground: false,
@@ -525,6 +529,28 @@ class MornyeTabBar extends StatelessWidget {
horizontal: floating ? 6 : 5,
vertical: 5,
),
interactionBuilder: (context, progress, child) => Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
// Only the capsule projects forward. Icons, labels and touch
// coordinates stay fixed, and collapse never fades the backdrop.
child: Transform.scale(
scaleX: 1 + 0.025 * progress,
scaleY: 1 + 0.06 * progress,
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.navigationOpacity(context),
refractionDepth: 0.45 + 0.15 * progress,
child: const SizedBox.expand(),
),
),
),
// The selection lens can swell beyond the capsule's clip.
FadeTransition(opacity: contentOpacity, child: child),
],
),
itemBuilder: (context, index, selected) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: 54),
child: Padding(
@@ -566,23 +592,5 @@ class MornyeTabBar extends StatelessWidget {
),
),
);
return Padding(
padding: EdgeInsets.symmetric(vertical: floating ? 8 : 0),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.navigationOpacity(context),
child: const SizedBox.expand(),
),
),
// Keep the held lens above the bar's clip so it can swell outward.
content,
],
),
);
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ class MornyeLiquidBackdrop extends StatelessWidget {
required this.child,
this.interaction = false,
this.progress = 1,
this.refractionDepth = 0.30,
});
final BorderRadius borderRadius;
@@ -19,6 +20,7 @@ class MornyeLiquidBackdrop extends StatelessWidget {
final Widget child;
final bool interaction;
final double progress;
final double refractionDepth;
@override
Widget build(BuildContext context) {
@@ -45,7 +47,7 @@ class MornyeLiquidBackdrop extends StatelessWidget {
refraction: LiquidGlassRefraction(
refractionType: OpticalRefraction(
refraction: 1.7,
depth: 0.30 * effect,
depth: refractionDepth * effect,
refractionWidth: 20,
),
chromaticAberration: 0.002 * clarity * effect,
+13 -31
View File
@@ -25,6 +25,7 @@ class MornyeSelectionPill extends StatefulWidget {
this.liquidInteraction = false,
this.selectionColor,
this.maskItemForeground = true,
this.interactionBuilder,
});
final List<String> labels;
@@ -41,6 +42,11 @@ class MornyeSelectionPill extends StatefulWidget {
final Color? selectionColor;
final bool maskItemForeground;
/// Lets the bar animate its glass behind the fixed-size foreground using the
/// same press/release animation as the selection lens.
final Widget Function(BuildContext context, double progress, Widget child)?
interactionBuilder;
@override
State<MornyeSelectionPill> createState() => _MornyeSelectionPillState();
}
@@ -141,7 +147,8 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
ui.ImageFilter.isShaderFilterSupported &&
!MediaQuery.disableAnimationsOf(context) &&
!MediaQuery.highContrastOf(context);
return Listener(
final pressProgress = lensEnabled ? _pressAnimation.value : 0.0;
final content = Listener(
// An accepted drag can end with a PointerCancelEvent. Do not navigate
// when the OS interrupts the gesture (e.g. opening system controls).
onPointerDown: (event) {
@@ -258,11 +265,11 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
),
),
if (lensEnabled && selected >= 0 && selected < count)
_interactionLens(selected, count, _pressAnimation.value),
_interactionLens(selected, count, pressProgress),
_foreground(
selected,
count,
lensEnabled ? _pressAnimation.value : 0,
pressProgress,
Row(
key: _foregroundKey,
children: [
@@ -289,27 +296,7 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
: Colors.transparent,
onTap: () => widget.onChanged(index),
child: ExcludeSemantics(
child: AnimatedScale(
scale:
lensEnabled && _pressed && selected >= 0
? 1 +
0.18 *
_coverage(
index,
selected,
count,
)
: 1,
duration:
_dragAlignment != null ||
MediaQuery.disableAnimationsOf(
context,
)
? Duration.zero
: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: _item(context, index, selected),
),
child: _item(context, index, selected),
),
),
),
@@ -324,6 +311,8 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
),
),
);
return widget.interactionBuilder?.call(context, pressProgress, content) ??
content;
}
Widget _item(BuildContext context, int index, int selected) {
@@ -337,13 +326,6 @@ class _MornyeSelectionPillState extends State<MornyeSelectionPill>
: child;
}
double _coverage(int index, int selected, int count) {
final position = _dragAlignment == null
? selected.toDouble()
: (_dragAlignment! + 1) * (count - 1) / 2;
return (1 - (index - position).abs()).clamp(0.0, 1.0);
}
Widget _foreground(
int selected,
int count,