From 37ddfd1047d306d1714dfcb07e4d51d7927d43d6 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Mon, 25 May 2026 17:20:37 -0400 Subject: [PATCH] feat: animations in bottom nav --- .../components/organisms/bottom_nav_bar.dart | 235 ++++++++++++++---- 1 file changed, 192 insertions(+), 43 deletions(-) diff --git a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart index 01195ae1..e6dc1f33 100644 --- a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart +++ b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/atoms/default_profile_avatar.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; -import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/features/notifications/providers/unread_count_provider.dart'; class SparkBottomNavBar extends ConsumerWidget { @@ -19,6 +18,10 @@ class SparkBottomNavBar extends ConsumerWidget { final ValueChanged onTap; final ImageProvider userAvatar; + static const _itemAnimationDuration = Duration(milliseconds: 150); + static const _iconSwitchDuration = Duration(milliseconds: 120); + static const _itemCurve = Curves.easeOutCubic; + @override Widget build(BuildContext context, WidgetRef ref) { final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -131,18 +134,111 @@ class _NavIcon extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + return _BottomNavTapTarget( onTap: onTap, - child: AnimatedContainer( - duration: AppConstants.animationFast, + reduceMotion: reduceMotion, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - child: builder(context, isSelected), + child: _AnimatedNavIcon( + isSelected: isSelected, + reduceMotion: reduceMotion, + selectedChild: builder(context, true), + unselectedChild: builder(context, false), + ), ), ); } } +class _BottomNavTapTarget extends StatefulWidget { + const _BottomNavTapTarget({ + required this.onTap, + required this.reduceMotion, + required this.child, + }); + + final VoidCallback onTap; + final bool reduceMotion; + final Widget child; + + @override + State<_BottomNavTapTarget> createState() => _BottomNavTapTargetState(); +} + +class _BottomNavTapTargetState extends State<_BottomNavTapTarget> { + bool _isPressed = false; + + void _setPressed(bool value) { + if (_isPressed == value) return; + setState(() => _isPressed = value); + } + + @override + Widget build(BuildContext context) { + final pressScale = _isPressed && !widget.reduceMotion ? 0.88 : 1.0; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => _setPressed(true), + onTapCancel: () => _setPressed(false), + onTapUp: (_) => _setPressed(false), + onTap: widget.onTap, + child: AnimatedScale( + scale: pressScale, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + child: widget.child, + ), + ); + } +} + +class _AnimatedNavIcon extends StatelessWidget { + const _AnimatedNavIcon({ + required this.isSelected, + required this.reduceMotion, + required this.selectedChild, + required this.unselectedChild, + }); + + final bool isSelected; + final bool reduceMotion; + final Widget selectedChild; + final Widget unselectedChild; + + @override + Widget build(BuildContext context) { + final duration = reduceMotion + ? Duration.zero + : SparkBottomNavBar._iconSwitchDuration; + + return Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + opacity: isSelected ? 0 : 1, + duration: duration, + curve: SparkBottomNavBar._itemCurve, + child: unselectedChild, + ), + AnimatedOpacity( + opacity: isSelected ? 1 : 0, + duration: duration, + curve: SparkBottomNavBar._itemCurve, + child: AnimatedScale( + scale: isSelected || reduceMotion ? 1 : 0.94, + duration: duration, + curve: SparkBottomNavBar._itemCurve, + child: selectedChild, + ), + ), + ], + ); + } +} + class _NavIconWithBadge extends StatelessWidget { const _NavIconWithBadge({ required this.isSelected, @@ -162,49 +258,56 @@ class _NavIconWithBadge extends StatelessWidget { Widget build(BuildContext context) { final count = badgeCount.value ?? 0; final showBadge = count > 0; + final reduceMotion = MediaQuery.disableAnimationsOf(context); - return GestureDetector( - behavior: HitTestBehavior.opaque, + return _BottomNavTapTarget( onTap: onTap, - child: AnimatedContainer( - duration: AppConstants.animationFast, + reduceMotion: reduceMotion, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), child: Stack( clipBehavior: Clip.none, children: [ - builder(context, isSelected), - if (showBadge) - Positioned( - right: -7, - top: -6, - child: Container( - padding: count < 10 - ? EdgeInsets.zero - : const EdgeInsets.symmetric(horizontal: 6), - constraints: BoxConstraints( - minWidth: count < 10 ? 18 : 20, - minHeight: 18, - ), - height: 18, - decoration: BoxDecoration( - color: AppColors.primary, - shape: count < 10 ? BoxShape.circle : BoxShape.rectangle, - borderRadius: count < 10 - ? null - : const BorderRadius.all(Radius.circular(9)), - ), - alignment: Alignment.center, - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, + _AnimatedNavIcon( + isSelected: isSelected, + reduceMotion: reduceMotion, + selectedChild: builder(context, true), + unselectedChild: builder(context, false), + ), + Positioned( + right: -7, + top: -6, + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : SparkBottomNavBar._iconSwitchDuration, + switchInCurve: SparkBottomNavBar._itemCurve, + switchOutCurve: SparkBottomNavBar._itemCurve, + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: SparkBottomNavBar._itemCurve, + ); + + return FadeTransition( + opacity: curvedAnimation, + child: ScaleTransition( + scale: Tween( + begin: 0.9, + end: 1, + ).animate(curvedAnimation), + child: child, ), - textAlign: TextAlign.center, - ), - ), + ); + }, + child: showBadge + ? _NotificationBadge( + key: ValueKey(count), + count: count, + ) + : const SizedBox.shrink(key: ValueKey(0)), ), + ), ], ), ), @@ -212,6 +315,43 @@ class _NavIconWithBadge extends StatelessWidget { } } +class _NotificationBadge extends StatelessWidget { + const _NotificationBadge({required this.count, super.key}); + + final int count; + + @override + Widget build(BuildContext context) { + return Container( + padding: count < 10 + ? EdgeInsets.zero + : const EdgeInsets.symmetric(horizontal: 6), + constraints: BoxConstraints( + minWidth: count < 10 ? 18 : 20, + minHeight: 18, + ), + height: 18, + decoration: BoxDecoration( + color: AppColors.primary, + shape: count < 10 ? BoxShape.circle : BoxShape.rectangle, + borderRadius: count < 10 + ? null + : const BorderRadius.all(Radius.circular(9)), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ); + } +} + class _ProfileAvatar extends StatelessWidget { const _ProfileAvatar({ required this.isSelected, @@ -229,7 +369,12 @@ class _ProfileAvatar extends StatelessWidget { // Always use dark mode when on home tab (index 0) final isDark = currentIndex == 0 || Theme.of(context).brightness == Brightness.dark; - final avatar = Container( + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final avatar = AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : SparkBottomNavBar._itemAnimationDuration, + curve: SparkBottomNavBar._itemCurve, width: 34, height: 34, decoration: BoxDecoration( @@ -244,6 +389,10 @@ class _ProfileAvatar extends StatelessWidget { ), child: image is AssetImage ? const DefaultProfileAvatar(size: 34) : null, ); - return GestureDetector(onTap: onTap, child: avatar); + return _BottomNavTapTarget( + onTap: onTap, + reduceMotion: reduceMotion, + child: avatar, + ); } } -- 2.51.2