feat(ui): add Material Expressive spring motion

This commit is contained in:
zarzet committed 2026-10-14 20:20:44 +07:00
1 parent e049604b24
commit 5e6afc7a32
10 files changed
+351 -36

No files matched your search

+9 -4
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/app_progress_indicator.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_refresh_indicator.dart';
import 'package:spotiflac_android/widgets/app_shape.dart';
@@ -875,6 +876,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
!hasHomeFeedExtension &&
!hasExploreContent &&
!hasResults;
final sectionMotion = AppMotion.spatial(
context,
const AppMotionSpec(Duration(milliseconds: 250), Curves.easeOut),
);
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
@@ -936,8 +941,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
duration: sectionMotion.duration,
curve: sectionMotion.curve,
child: (hasResults || showExplore || !_showsHome)
? const SizedBox.shrink()
: _buildHomeIntro(
@@ -1010,8 +1015,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
duration: sectionMotion.duration,
curve: sectionMotion.curve,
child:
(!_showsHome ||
hasResults ||
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
/// A duration and curve pair for implicit animations.
@immutable
class AppMotionSpec {
const AppMotionSpec(this.duration, this.curve);
final Duration duration;
final Curve curve;
}
/// A [Curve] that follows an [M3ESpring] released from rest at 0 toward 1.
///
/// [duration] is the spring's settling time, so implicit animations driven by
/// this curve end exactly when the physical motion does.
class SpringCurve extends Curve {
SpringCurve(M3ESpring spring)
: this._(SpringSimulation(spring.toDescription(), 0, 1, 0));
SpringCurve._(this._simulation) : duration = _settleTime(_simulation);
final SpringSimulation _simulation;
final Duration duration;
static Duration _settleTime(SpringSimulation simulation) {
const step = 1 / 1000;
var settledSince = 0.0;
for (var t = 0.0; t < 2; t += step) {
final settled =
(simulation.x(t) - 1).abs() < 0.002 && simulation.dx(t).abs() < 0.02;
if (!settled) {
settledSince = t + step;
} else if (t - settledSince >= 0.05) {
break;
}
}
return Duration(microseconds: (settledSince * 1e6).round());
}
@override
double transformInternal(double t) =>
_simulation.x(t * duration.inMicroseconds / 1e6);
}
/// Material 3 Expressive motion for the Material theme.
///
/// Spatial springs (position, size, scale) overshoot slightly; effects
/// springs (opacity, color) settle without overshoot. Mornye and reduced
/// motion keep each call site's original [AppMotionSpec].
abstract final class AppMotion {
static final _spatialFast = SpringCurve(M3EMotion.expressiveSpatialFast);
static final _spatialDefault = SpringCurve(
M3EMotion.expressiveSpatialDefault,
);
static final _spatialSlow = SpringCurve(M3EMotion.expressiveSpatialSlow);
static final _effectsFast = SpringCurve(M3EMotion.effectsFast);
static final _effectsDefault = SpringCurve(M3EMotion.effectsDefault);
static AppMotionSpec _spec(SpringCurve curve) =>
AppMotionSpec(curve.duration, curve);
/// Fast spatial spring for small components: chips, checks, buttons.
static AppMotionSpec spatialFast(
BuildContext context,
AppMotionSpec fallback,
) => materialExpressiveEnabled(context) ? _spec(_spatialFast) : fallback;
/// Default spatial spring for list items, panels and content changes.
static AppMotionSpec spatial(BuildContext context, AppMotionSpec fallback) =>
materialExpressiveEnabled(context) ? _spec(_spatialDefault) : fallback;
/// Slow spatial spring for full-screen surfaces such as the player.
static AppMotionSpec spatialSlow(
BuildContext context,
AppMotionSpec fallback,
) => materialExpressiveEnabled(context) ? _spec(_spatialSlow) : fallback;
/// Fast effects spring for opacity and color.
static AppMotionSpec effectsFast(
BuildContext context,
AppMotionSpec fallback,
) => materialExpressiveEnabled(context) ? _spec(_effectsFast) : fallback;
/// Default effects spring for opacity and color.
static AppMotionSpec effects(BuildContext context, AppMotionSpec fallback) =>
materialExpressiveEnabled(context) ? _spec(_effectsDefault) : fallback;
}
+48 -3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
/// Wraps a child in a staggered fade-in + slide-up animation.
@@ -33,6 +35,40 @@ class StaggeredListItem extends StatelessWidget {
}
final cappedIndex = index.clamp(0, _maxAnimatedItems - 1);
final delay = staggerDelay * cappedIndex;
if (materialExpressiveEnabled(context)) {
// Expressive: each item springs up after its own delay and may
// overshoot slightly; opacity stays within range.
final motion = AppMotion.spatial(
context,
AppMotionSpec(duration, Curves.easeOutCubic),
);
final total = motion.duration + delay;
final delayFraction = total.inMicroseconds > 0
? delay.inMicroseconds / total.inMicroseconds
: 0.0;
return TweenAnimationBuilder<double>(
key: ValueKey('stagger_$index'),
tween: Tween(begin: 0.0, end: 1.0),
duration: total,
builder: (context, value, child) {
final elapsed = value <= delayFraction
? 0.0
: ((value - delayFraction) / (1.0 - delayFraction)).clamp(
0.0,
1.0,
);
final progress = motion.curve.transform(elapsed);
return Opacity(
opacity: progress.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, 16 * (1 - progress)),
child: child,
),
);
},
child: child,
);
}
final totalDuration = duration + delay;
return TweenAnimationBuilder<double>(
@@ -1037,12 +1073,21 @@ class AnimatedSelectionCheckbox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scale = AppMotion.spatialFast(
context,
const AppMotionSpec(Duration(milliseconds: 200), Curves.easeOutBack),
);
final fill = AppMotion.effectsFast(
context,
const AppMotionSpec(Duration(milliseconds: 200), Curves.linear),
);
return AnimatedScale(
scale: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
duration: scale.duration,
curve: scale.curve,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
duration: fill.duration,
curve: fill.curve,
width: size,
height: size,
decoration: BoxDecoration(
+33
View File
@@ -1,6 +1,8 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
@@ -24,6 +26,37 @@ Future<T?> showAppDialog<T>({
),
);
}
if (materialExpressiveEnabled(context)) {
// Expressive: the dialog scales in on a spatial spring while an effects
// spring fades it, keeping opacity within range.
const fallback = AppMotionSpec(Duration.zero, Curves.linear);
final scale = AppMotion.spatial(context, fallback);
final fade = AppMotion.effectsFast(context, fallback);
final navigator = Navigator.of(context, rootNavigator: useRootNavigator);
final themes = InheritedTheme.capture(from: context, to: navigator.context);
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: DialogTheme.of(context).barrierColor ?? Colors.black54,
useRootNavigator: useRootNavigator,
transitionDuration: scale.duration,
pageBuilder: (dialogContext, _, _) =>
themes.wrap(SafeArea(child: Builder(builder: builder))),
transitionBuilder: (context, animation, _, child) => FadeTransition(
opacity: animation.drive(CurveTween(curve: fade.curve)),
child: ScaleTransition(
scale: animation.drive(
Tween<double>(
begin: 0.85,
end: 1,
).chain(CurveTween(curve: scale.curve)),
),
child: child,
),
),
);
}
return showDialog<T>(
context: context,
builder: builder,
+17
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'
show CupertinoButton, CupertinoDynamicColor, kCupertinoModalBarrierColor;
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
@@ -182,8 +184,23 @@ Future<T?> showAppModalBottomSheet<T>({
Color? backgroundColor,
}) {
final mornye = context.isMornye;
// Expressive sheets rise on a critically damped spring, so they never
// overshoot their own height and reveal the barrier underneath.
final spring = materialExpressiveEnabled(context)
? AppMotion.spatialSlow(
context,
const AppMotionSpec(Duration.zero, Curves.linear),
)
: null;
return showModalBottomSheet<T>(
context: context,
sheetAnimationStyle: spring == null
? null
: AnimationStyle(
duration: spring.duration,
curve: spring.curve,
reverseDuration: const Duration(milliseconds: 200),
),
isScrollControlled: isScrollControlled,
isDismissible: isDismissible,
enableDrag: enableDrag,
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
@@ -54,6 +55,7 @@ class _ExpressivePlaybackTransportState
static const _sideActionWidth = 48.0;
static const _minTarget = 48.0;
static const _squishRatio = 0.15;
static final _pressSpring = SpringCurve(M3EMotion.expressiveSpatialPress);
int? _pressed;
@@ -178,8 +180,8 @@ class _ExpressivePlaybackTransportState
// One curve for every slot keeps the summed width constant mid-flight.
child: TweenAnimationBuilder<double>(
tween: Tween(end: width),
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutBack,
duration: _pressSpring.duration,
curve: _pressSpring,
builder: (context, width, child) =>
SizedBox(width: math.max(0, width), height: height, child: child),
child: child,
+28 -12
View File
@@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -47,17 +49,23 @@ class MaterialPlayerLayout extends StatelessWidget {
final resting =
!ref.watch(playbackPlayingProvider) &&
!ref.watch(playbackLoadingProvider);
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 500);
final motion = AppMotion.spatialSlow(
context,
AppMotionSpec(
MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 500),
Curves.easeOutBack,
),
);
return AnimatedScale(
scale: resting ? 0.9 : 1,
duration: duration,
curve: Curves.easeOutBack,
duration: motion.duration,
curve: motion.curve,
child: TweenAnimationBuilder<double>(
tween: Tween(end: resting ? 40 : 20),
duration: duration,
curve: Curves.easeOutCubic,
duration: motion.duration,
curve: motion.curve,
builder: (context, radius, artwork) => ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: artwork,
@@ -175,20 +183,28 @@ class MaterialPlayerLayout extends StatelessWidget {
BuildContext context, {
bool compact = false,
}) {
final expressive = materialExpressiveEnabled(context);
const fallback = AppMotionSpec(Duration(milliseconds: 350), Curves.linear);
final slide = AppMotion.spatial(context, fallback);
final fade = AppMotion.effects(context, fallback);
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
duration: slide.duration,
// Expressive: a spatial spring moves the title while an effects
// spring fades it, so opacity never overshoots.
switchInCurve: expressive ? Curves.linear : Curves.easeOutCubic,
switchOutCurve: expressive ? Curves.linear : Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
opacity: expressive
? animation.drive(CurveTween(curve: fade.curve))
: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(animation),
).chain(CurveTween(curve: slide.curve)).animate(animation),
child: child,
),
),
+31 -13
View File
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/scheduler.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
@@ -183,19 +184,29 @@ class _AnimatedSelectionBottomBarState extends State<AnimatedSelectionBottomBar>
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
final curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
_controller = AnimationController(vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller.duration != null) return;
// Expressive: the bar springs into place while fading without overshoot.
const fallback = AppMotionSpec(
Duration(milliseconds: 240),
Curves.easeOutCubic,
);
final slide = AppMotion.spatial(context, fallback);
final fade = AppMotion.effects(context, fallback);
_controller.duration = slide.duration;
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curve);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
).animate(CurvedAnimation(parent: _controller, curve: slide.curve));
_fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: fade.curve));
_controller.forward();
}
@@ -493,12 +504,19 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag>
void _setExpanded(bool expanded) {
setState(() => _expanded = expanded);
if (!expanded && _scroll.hasClients) _scroll.jumpTo(0);
final motion = AppMotion.spatialSlow(
context,
AppMotionSpec(
MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
Curves.easeOutCubic,
),
);
_expansion.animateTo(
expanded ? 1 : 0,
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
duration: motion.duration,
curve: motion.curve,
);
_animateToRest(0);
}
+7 -2
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
@@ -173,12 +174,16 @@ class _SettingsSearchTargetState extends State<SettingsSearchTarget> {
if (!_handled) return widget.child;
final colorScheme = Theme.of(context).colorScheme;
final motion = AppMotion.effects(
context,
AppMotionSpec(context.tokens.motionMedium, Curves.easeOutCubic),
);
return Semantics(
selected: _highlighted,
child: AnimatedContainer(
key: ValueKey('settings-highlight:${widget.label}'),
duration: context.tokens.motionMedium,
curve: Curves.easeOutCubic,
duration: motion.duration,
curve: motion.curve,
color: _highlighted
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
void main() {
test('spring curves start at rest and settle within their duration', () {
for (final spring in [
M3EMotion.expressiveSpatialFast,
M3EMotion.expressiveSpatialDefault,
M3EMotion.expressiveSpatialSlow,
M3EMotion.effectsFast,
M3EMotion.effectsDefault,
]) {
final curve = SpringCurve(spring);
expect(curve.transform(0), 0);
expect(curve.transform(1), 1);
expect(curve.transform(0.98), closeTo(1, 0.01));
expect(curve.duration.inMilliseconds, inInclusiveRange(100, 1200));
}
});
test('spatial springs overshoot while effects springs do not', () {
double peak(SpringCurve curve) => [
for (var t = 0.0; t <= 1; t += 0.01) curve.transform(t),
].reduce((a, b) => a > b ? a : b);
expect(
peak(SpringCurve(M3EMotion.expressiveSpatialDefault)),
greaterThan(1),
);
expect(peak(SpringCurve(M3EMotion.effectsDefault)), lessThanOrEqualTo(1));
});
Future<AppMotionSpec> resolve(
WidgetTester tester, {
required ThemeData theme,
bool reduceMotion = false,
}) async {
late AppMotionSpec spec;
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Builder(
builder: (context) {
spec = AppMotion.spatial(
context,
const AppMotionSpec(
Duration(milliseconds: 250),
Curves.easeOutCubic,
),
);
return const SizedBox();
},
),
),
),
);
// MaterialApp animates theme changes between pumps.
await tester.pumpAndSettle();
return spec;
}
testWidgets(
'Material uses springs; Mornye and reduced motion keep fallback',
(tester) async {
expect(
(await resolve(tester, theme: AppTheme.light())).curve,
isA<SpringCurve>(),
);
for (final spec in [
await resolve(tester, theme: MornyeTheme.build(Brightness.light)),
await resolve(tester, theme: AppTheme.light(), reduceMotion: true),
]) {
expect(spec.curve, Curves.easeOutCubic);
expect(spec.duration, const Duration(milliseconds: 250));
}
},
);
}