Created
November 12, 2025 03:10
-
-
Save AndrewDongminYoo/b5bd25feaf20ab714ba524e1094477c2 to your computer and use it in GitHub Desktop.
A custom snackbar widget that slides down from beneath the app bar area.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 🎯 Dart imports: | |
| import 'dart:async'; | |
| import 'dart:math' as math; | |
| // 🐦 Flutter imports: | |
| import 'package:flutter/cupertino.dart'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| /// Extension on [BuildContext] that provides convenient methods for displaying | |
| /// snack bars throughout the application. | |
| extension SnackBarMessengerExt on BuildContext { | |
| void slideDownSnackBar( | |
| String message, [ | |
| SlideDownSnackBarType type = SlideDownSnackBarType.success, | |
| Duration duration = SlideDownSnackBar.defaultDuration, | |
| ]) { | |
| if (mounted) { | |
| SlideDownSnackBar.show(this, message, type: type, duration: duration); | |
| } | |
| } | |
| } | |
| /// A custom snackbar widget that slides down from beneath the app bar area. | |
| /// | |
| /// [SlideDownSnackBar] provides a non-intrusive way to display temporary messages | |
| /// with smooth slide animations and automatic dismissal. Unlike Material's | |
| /// SnackBar, this widget positions itself below the app bar for better | |
| /// visual hierarchy and user experience. | |
| /// | |
| /// ## Features | |
| /// | |
| /// - **Slide Animation**: Smooth slide-down entrance with configurable duration | |
| /// - **Auto-dismissal**: Automatically removes after specified duration | |
| /// - **Multiple Types**: Support for loading, success, failure, and plain messages | |
| /// - **Overlay Management**: Handles multiple instances with proper cleanup | |
| /// - **Responsive Layout**: Adapts margins based on content width | |
| /// - **Accessibility**: Full semantic labeling and live region support | |
| /// - **Error Handling**: Robust error handling for edge cases | |
| /// - **Performance Optimized**: Caches calculations and properly manages resources | |
| /// | |
| /// ## Usage | |
| /// | |
| /// ```dart | |
| /// // Basic usage | |
| /// SlideDownSnackBar.show(context, 'Operation completed successfully'); | |
| /// | |
| /// // With custom type and duration | |
| /// SlideDownSnackBar.show( | |
| /// context, | |
| /// 'Processing your request...', | |
| /// type: SlideDownSnackBarType.loading, | |
| /// duration: Duration(seconds: 6), | |
| /// ); | |
| /// ``` | |
| /// | |
| /// ## Layout Behavior | |
| /// | |
| /// The widget automatically calculates optimal horizontal margins based on: | |
| /// - Content width (text + icon + padding) | |
| /// - Screen width constraints | |
| /// - Minimum margin requirements (38) | |
| /// - Maximum content width limits | |
| /// | |
| /// For oversized content or plain type messages, minimum margins are applied. | |
| /// Otherwise, the widget centers itself with clamped margins (38 - 60). | |
| class SlideDownSnackBar extends StatefulWidget { | |
| /// Creates a [SlideDownSnackBar] widget. | |
| /// | |
| /// The [message] parameter is required and contains the text to display. | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [message]: The text content to display in the snackbar | |
| /// - [type]: Visual type determining icon and styling (defaults to success) | |
| /// - [duration]: How long the snackbar remains visible (defaults to 4 seconds) | |
| /// - [animationDuration]: Duration of the entrance/exit animation | |
| /// - [animationCurve]: Curve for the entrance/exit animation | |
| /// - [backgroundColor]: Optional background color override | |
| const SlideDownSnackBar({ | |
| super.key, | |
| required this.message, | |
| this.type = SlideDownSnackBarType.success, | |
| this.duration = defaultDuration, | |
| this.animationDuration = defaultAnimationDuration, | |
| this.animationCurve = Curves.easeInOut, | |
| this.backgroundColor = defaultColor, | |
| }); | |
| /// The text message displayed in the snackbar. | |
| final String message; | |
| /// The visual type of the snackbar, determining icon and semantic meaning. | |
| final SlideDownSnackBarType type; | |
| /// Duration the snackbar remains visible before auto-dismissal. | |
| final Duration duration; | |
| /// Animation duration for the slide transition. | |
| final Duration animationDuration; | |
| /// Animation curve for the slide transition. | |
| final Curve animationCurve; | |
| /// Background color override. Defaults to [defaultColor] when null. | |
| final Color backgroundColor; | |
| /// Slides in a [SlideDownSnackBar] in the given context. | |
| /// | |
| /// This static method handles the complete lifecycle of showing a snackbar: | |
| /// 1. Dismisses any existing snackbar | |
| /// 2. Creates and inserts a new overlay entry | |
| /// 3. Schedules automatic removal after [duration] + animation time | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [context]: The build context for overlay insertion | |
| /// - [message]: Text content to display | |
| /// - [type]: Visual type (defaults to success) | |
| /// - [duration]: Display duration (defaults to 4 seconds) | |
| /// - [dismissDirection]: Swipe direction for manual dismissal (currently unused) | |
| /// - [overlay]: Optional overlay state to use instead of root overlay | |
| /// | |
| /// ## Implementation Notes | |
| /// | |
| /// - Uses root overlay to ensure visibility above all content | |
| /// - Includes 300ms buffer for exit animation completion | |
| /// - Automatically manages overlay entry lifecycle | |
| /// - Includes robust error handling for edge cases | |
| /// | |
| /// ```dart | |
| /// SlideDownSnackBar.show(context, 'File uploaded successfully'); | |
| /// ``` | |
| /// | |
| /// ## Error Handling | |
| /// | |
| /// This method includes comprehensive error handling: | |
| /// - Checks if overlay is mounted before insertion | |
| /// - Catches and logs exceptions without crashing the app | |
| /// - Provides debug information in development mode | |
| static void show( | |
| BuildContext context, | |
| String message, { | |
| SlideDownSnackBarType type = SlideDownSnackBarType.success, | |
| Duration duration = defaultDuration, | |
| DismissDirection dismissDirection = DismissDirection.horizontal, | |
| OverlayState? overlay, | |
| }) { | |
| try { | |
| _SnackBarManager.hide(); | |
| final targetOverlay = overlay ?? Overlay.of(context, rootOverlay: true); | |
| if (!targetOverlay.mounted) { | |
| if (kDebugMode) { | |
| debugPrint('SlideDownSnackBar: Overlay not mounted, cannot show snackbar'); | |
| } | |
| return; | |
| } | |
| // Create new overlay entry with snackbar widget | |
| final entry = OverlayEntry( | |
| builder: (_) => SlideDownSnackBar( | |
| message: message, | |
| type: type, | |
| duration: duration, | |
| ), | |
| ); | |
| // Insert into overlay and store reference | |
| targetOverlay.insert(entry); | |
| _SnackBarManager.show(entry, duration + defaultAnimationDuration); | |
| } catch (e, stackTrace) { | |
| if (kDebugMode) { | |
| debugPrint('SlideDownSnackBar error: $e'); | |
| debugPrintStack(stackTrace: stackTrace); | |
| } | |
| } | |
| } | |
| /// Hides the currently displayed snackbar if present. | |
| /// | |
| /// This method provides a safe way to manually dismiss any active snackbar. | |
| /// It handles cleanup of timers and overlay entries properly. | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [entry]: Optional specific overlay entry to hide. If null, hides the current active snackbar. | |
| /// | |
| /// ## Usage | |
| /// | |
| /// ```dart | |
| /// // Hide current snackbar | |
| /// SlideDownSnackBar.hide(); | |
| /// ``` | |
| static void hide({OverlayEntry? entry}) { | |
| _SnackBarManager.hide(entry: entry); | |
| } | |
| /// Disposes all static resources and cleans up any active snackbars. | |
| /// | |
| /// This method should be called when the app is shutting down or when | |
| /// you need to ensure all snackbar resources are properly cleaned up. | |
| /// | |
| /// ## Usage | |
| /// | |
| /// ```dart | |
| /// // In app disposal or cleanup | |
| /// SlideDownSnackBar.dispose(); | |
| /// ``` | |
| static void dispose() { | |
| _SnackBarManager.dispose(); | |
| } | |
| /// Default background color following design system specifications. | |
| /// | |
| /// Uses dark gray ([Color(0xFF171717)]) to ensure proper contrast | |
| /// with white text content and maintain visual hierarchy. | |
| static const Color defaultColor = Color(0xFF171717); | |
| /// Standard animation duration for slide transitions. | |
| /// | |
| /// Set to 300 milliseconds to provide smooth animations that feel | |
| /// responsive without being too fast or slow. | |
| static const Duration defaultAnimationDuration = Duration(milliseconds: 300); | |
| /// Standard display duration for auto-dismissal. | |
| /// | |
| /// Set to 2 seconds to provide sufficient reading time while maintaining | |
| /// a non-intrusive user experience. Based on accessibility guidelines | |
| /// for temporary message visibility. | |
| static const Duration defaultDuration = Duration(milliseconds: 2000); | |
| @override | |
| State<SlideDownSnackBar> createState() => SlideDownSnackBarState(); | |
| } | |
| /// Internal manager class for handling snackbar state and lifecycle. | |
| /// | |
| /// This class encapsulates the static state management for snackbars, | |
| /// providing better testability and separation of concerns. | |
| class _SnackBarManager { | |
| /// Reference to the currently active overlay entry. | |
| /// | |
| /// Used internally for managing multiple snackbar instances and ensuring | |
| /// only one snackbar is visible at a time. | |
| static OverlayEntry? _currentEntry; | |
| /// Timer for the currently active snackbar's auto-dismissal. | |
| /// | |
| /// Canceled when a new snackbar is shown or when dismissal occurs. | |
| static Timer? _currentTimer; | |
| /// Shows a new snackbar with the given overlay entry and duration. | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [entry]: The overlay entry to show | |
| /// - [totalDuration]: Total duration including animation time | |
| static void show(OverlayEntry entry, Duration totalDuration) { | |
| _currentEntry = entry; | |
| _currentTimer = Timer(totalDuration, () => hide(entry: entry)); | |
| } | |
| /// Hides the currently displayed snackbar if present. | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [entry]: Optional specific overlay entry to hide | |
| static void hide({OverlayEntry? entry}) { | |
| _currentTimer?.cancel(); | |
| _currentTimer = null; | |
| final targetEntry = entry ?? _currentEntry; | |
| try { | |
| targetEntry?.remove(); | |
| } catch (error) { | |
| if (kDebugMode) { | |
| debugPrint('Error removing snackbar overlay: $error'); | |
| } | |
| } | |
| if (entry == null || entry == _currentEntry) { | |
| _currentEntry = null; | |
| } | |
| } | |
| /// Disposes all resources and cleans up active snackbars. | |
| static void dispose() { | |
| hide(); | |
| } | |
| } | |
| /// Private state class managing animations and lifecycle for [SlideDownSnackBar]. | |
| /// | |
| /// Handles the slide-in/slide-out animations and coordinates the timing | |
| /// between widget display duration and animation states. Includes performance | |
| /// optimizations through caching and proper resource management. | |
| @visibleForTesting | |
| class SlideDownSnackBarState extends State<SlideDownSnackBar> with SingleTickerProviderStateMixin { | |
| /// Animation controller for slide transitions. | |
| late AnimationController _controller; | |
| /// Slide offset animation for vertical movement. | |
| /// | |
| /// Animates from `Offset(0, -1)` (fully above viewport) to `Offset.zero` | |
| /// (final position). Uses threshold reverse curve to prevent exit animation | |
| /// from interfering with entrance. | |
| late Animation<Offset> _offset; | |
| /// Timer for scheduling exit animation. | |
| Timer? _dismissTimer; | |
| /// Cached horizontal margin calculation to avoid repeated TextPainter operations. | |
| double? _cachedHorizontalMargin; | |
| /// Last screen size used for margin calculation caching. | |
| Size? _lastScreenSize; | |
| /// Last message used for margin calculation caching. | |
| String? _lastMessage; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController( | |
| duration: widget.animationDuration, | |
| debugLabel: 'SlideDownSnackBar', | |
| vsync: this, | |
| ); | |
| _offset = Tween(begin: const Offset(0, -1), end: Offset.zero).animate( | |
| CurvedAnimation( | |
| parent: _controller, | |
| curve: widget.animationCurve, | |
| reverseCurve: const Threshold(0), | |
| ), | |
| ); | |
| // Start entrance animation immediately | |
| unawaited(_controller.forward()); | |
| // Schedule exit animation after display duration | |
| _dismissTimer = Timer(widget.duration, () => mounted ? _controller.reverse() : null); | |
| } | |
| @override | |
| void dispose() { | |
| _dismissTimer?.cancel(); | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Positioned( | |
| top: overlayTop, | |
| left: overlayHorizontal, | |
| right: overlayHorizontal, | |
| child: SlideTransition( | |
| position: _offset, | |
| child: Semantics( | |
| container: true, | |
| liveRegion: true, | |
| label: semanticLabel, | |
| child: Material( | |
| elevation: 3, | |
| color: widget.backgroundColor, | |
| borderRadius: BorderRadius.circular(99), | |
| child: Container( | |
| padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12), | |
| decoration: BoxDecoration( | |
| color: widget.backgroundColor, | |
| borderRadius: BorderRadius.circular(99), | |
| ), | |
| child: _SnackBarContent( | |
| message: widget.message, | |
| type: widget.type, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| /// Generates semantic label for accessibility. | |
| /// | |
| /// Combines the type-specific label with the message content to provide | |
| /// clear context for screen readers. | |
| /// | |
| /// ## Returns | |
| /// | |
| /// A formatted string that includes both the snackbar type and message, | |
| /// optimized for screen reader comprehension. | |
| String get semanticLabel { | |
| final typeLabel = switch (widget.type) { | |
| SlideDownSnackBarType.success => '성공', | |
| SlideDownSnackBarType.failure => '오류', | |
| SlideDownSnackBarType.loading => '로딩 중', | |
| SlideDownSnackBarType.plain => '', | |
| }; | |
| return typeLabel.isEmpty ? widget.message : '$typeLabel: ${widget.message}'; | |
| } | |
| /// Calculates the top position for overlay placement. | |
| /// | |
| /// Positions the snackbar below: | |
| /// - System status bar (view padding top) | |
| /// - System view insets (keyboard, etc.) | |
| /// - App bar (with minimum height of 64) | |
| /// - Additional 32 spacing for visual separation | |
| /// | |
| /// ## Return Value | |
| /// | |
| /// Returns the calculated top offset in logical pixels, ensuring the | |
| /// snackbar appears in the appropriate visual hierarchy. | |
| double get overlayTop { | |
| return MediaQuery.viewInsetsOf(context).top + | |
| MediaQuery.viewPaddingOf(context).top + | |
| math.max(Theme.of(context).appBarTheme.toolbarHeight ?? 50, 64) + | |
| 32; | |
| } | |
| /// Calculates optimal horizontal margins based on content width. | |
| /// | |
| /// Uses sophisticated layout calculation with caching to determine the best | |
| /// horizontal positioning for the snackbar. Caches results based on screen | |
| /// size and message content to avoid expensive TextPainter operations. | |
| /// | |
| /// ## Calculation Factors | |
| /// | |
| /// - **Text Content**: Measures actual text width using [TextPainter] | |
| /// - **Icon Width**: Adds icon size + spacing when applicable | |
| /// - **Padding**: Accounts for internal horizontal padding (44 total) | |
| /// - **Screen Constraints**: Respects minimum (38) and maximum (60) margins | |
| /// | |
| /// ## Caching Strategy | |
| /// | |
| /// Results are cached based on: | |
| /// - Current screen size | |
| /// - Message content | |
| /// - Widget type (affects icon presence) | |
| /// | |
| /// ## Logic Flow | |
| /// | |
| /// 1. **Cache Check**: Returns cached value if screen size and message unchanged | |
| /// 2. **Text Measurement**: Uses exact text style to calculate rendered width | |
| /// 3. **Icon Contribution**: Adds icon dimensions if present (14 + 4 spacing) | |
| /// 4. **Total Width**: Sums text + icon + padding for complete content width | |
| /// 5. **Margin Calculation**: | |
| /// - If content exceeds max width OR type is plain: use minimum margin | |
| /// - Otherwise: center with clamped margins (38 - 60 range) | |
| /// 6. **Cache Update**: Stores result for future use | |
| /// | |
| /// ## Return Value | |
| /// | |
| /// Returns the calculated horizontal margin that ensures optimal visual | |
| /// balance and readability across different screen sizes and content lengths. | |
| double get overlayHorizontal { | |
| final currentSize = MediaQuery.sizeOf(context); | |
| // Check cache validity | |
| if (_cachedHorizontalMargin != null && _lastScreenSize == currentSize && _lastMessage == widget.message) { | |
| return _cachedHorizontalMargin!; | |
| } | |
| final textPainter = TextPainter( | |
| text: TextSpan( | |
| text: widget.message, | |
| style: const TextStyle( | |
| fontSize: 12, | |
| color: Colors.white, | |
| fontWeight: FontWeight.w500, | |
| fontFamily: 'Pretendard', | |
| height: 1.10, | |
| ), | |
| ), | |
| textAlign: TextAlign.center, | |
| textDirection: TextDirection.ltr, | |
| ); | |
| try { | |
| textPainter.layout(); | |
| // Calculate icon contribution to total width | |
| var iconWidth = 0.0; | |
| if (widget.type.icon != null) { | |
| iconWidth = 14 + 4; // Icon size + spacing | |
| } | |
| // Compute total content width including all elements | |
| final contentWidth = textPainter.width + iconWidth + 22 * 2; // 22 * 2 for horizontal padding | |
| double horizontalMargin; | |
| // Determine maximum content width based on minimum margin constraints | |
| final maxContentWidth = currentSize.height - (38 * 2); | |
| // Apply margin calculation logic | |
| if (contentWidth > maxContentWidth || widget.type == SlideDownSnackBarType.plain) { | |
| // Use minimum margin for oversized content or plain type | |
| horizontalMargin = 38; | |
| } else { | |
| // Calculate centered position with boundary constraints | |
| horizontalMargin = (currentSize.height - contentWidth) / 2; | |
| horizontalMargin = horizontalMargin.clamp(38, 60); | |
| } | |
| // Update cache | |
| _cachedHorizontalMargin = horizontalMargin; | |
| _lastScreenSize = currentSize; | |
| _lastMessage = widget.message; | |
| return horizontalMargin; | |
| } finally { | |
| // Properly dispose TextPainter to prevent resource leaks | |
| textPainter.dispose(); | |
| } | |
| } | |
| } | |
| /// Internal widget for rendering snackbar content. | |
| /// | |
| /// Handles the layout of icon and text content with proper spacing | |
| /// and alignment. Separates content rendering concerns from the | |
| /// main snackbar widget logic. | |
| class _SnackBarContent extends StatelessWidget { | |
| /// Creates a [_SnackBarContent] widget. | |
| /// | |
| /// ## Parameters | |
| /// | |
| /// - [message]: The text message to display | |
| /// - [type]: The snackbar type determining icon presence and styling | |
| const _SnackBarContent({ | |
| required this.message, | |
| required this.type, | |
| }); | |
| /// The text message to display in the snackbar. | |
| final String message; | |
| /// The type of snackbar, determining visual treatment. | |
| final SlideDownSnackBarType type; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Row( | |
| mainAxisSize: MainAxisSize.min, | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| spacing: 4, | |
| children: [ | |
| ?type.icon, | |
| Expanded( | |
| child: Text( | |
| message, | |
| textAlign: TextAlign.center, | |
| style: const TextStyle( | |
| fontSize: 12, | |
| color: Colors.white, | |
| fontWeight: FontWeight.w500, | |
| fontFamily: 'Pretendard', | |
| height: 1.10, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| /// Enumeration defining visual types for [SlideDownSnackBar] display. | |
| /// | |
| /// Each type provides distinct visual treatment and semantic meaning: | |
| /// | |
| /// ## Type Specifications | |
| /// | |
| /// - **[loading]**: Animated indicator for ongoing operations | |
| /// - **[failure]**: Error state with exclamation icon | |
| /// - **[success]**: Completion state with checkmark icon | |
| /// - **[plain]**: Text-only display without visual indicators | |
| /// | |
| /// Types automatically determine appropriate icons, colors, and accessibility | |
| /// labels through the companion extension. | |
| /// | |
| /// ## Usage | |
| /// | |
| /// ```dart | |
| /// SlideDownSnackBar.show( | |
| /// context, | |
| /// 'Loading data...', | |
| /// type: SlideDownSnackBarType.loading, | |
| /// ); | |
| /// ``` | |
| enum SlideDownSnackBarType { | |
| /// Indicates an ongoing operation with animated loading indicator. | |
| /// | |
| /// Displays a three-bounce animation to indicate active processing. | |
| /// Typically used for network requests, file operations, or other | |
| /// time-consuming tasks. | |
| loading, | |
| /// Indicates an error or failed operation with warning icon. | |
| /// | |
| /// Uses a red exclamation mark icon to clearly communicate failure | |
| /// states. Should be used for error messages that require user attention. | |
| failure, | |
| /// Indicates successful completion with checkmark icon. | |
| /// | |
| /// Displays a green checkmark to reinforce positive outcomes. | |
| /// Used for confirmation messages and successful operation completion. | |
| success, | |
| /// Plain text display without accompanying visual indicators. | |
| /// | |
| /// Provides a neutral message display without specific semantic meaning. | |
| /// Useful for informational messages that don't fit other categories. | |
| plain, | |
| } | |
| /// Extension providing icon widgets for each [SlideDownSnackBarType]. | |
| /// | |
| /// Maps enum values to their corresponding visual representations with | |
| /// consistent sizing, appropriate colors, and accessibility compliance. | |
| /// | |
| /// ## Icon Specifications | |
| /// | |
| /// All icons follow design system standards: | |
| /// - Consistent sizing (12-14px range) | |
| /// - Semantic color coding | |
| /// - Accessibility labels for screen readers | |
| /// - Optimized visual weight for readability | |
| extension /* SlideDownSnackBarTypeExtension */ on SlideDownSnackBarType { | |
| /// Returns the appropriate icon widget for this snackbar type. | |
| /// | |
| /// ## Icon Details | |
| /// | |
| /// - **Loading**: Custom three-bounce animation (12px, white) | |
| /// - 1.3s animation duration for smooth loading indication | |
| /// - White color for contrast against dark background | |
| /// - Includes semantic label for accessibility | |
| /// | |
| /// - **Failure**: Exclamation circle (14px, error red) | |
| /// - Uses [CupertinoIcons.exclamationmark_circle] for consistency | |
| /// - Error red color ([Color(0xFFF03224)]) for immediate recognition | |
| /// - Semantic label indicates failure state | |
| /// | |
| /// - **Success**: Checkmark (14px, success green) | |
| /// - Material [Icons.check] for universal recognition | |
| /// - Success green ([Color(0xFF46CB53)]) for positive reinforcement | |
| /// - Semantic label indicates success state | |
| /// | |
| /// - **Plain**: No icon (returns null) | |
| /// - Allows for text-only messages without visual clutter | |
| /// - Maintains consistent spacing when no icon is needed | |
| /// | |
| /// All icons include semantic labels for accessibility compliance and | |
| /// screen reader support. | |
| /// | |
| /// ## Returns | |
| /// | |
| /// A [Widget] representing the appropriate icon for the snackbar type, | |
| /// or null for plain type messages. | |
| Widget? get icon { | |
| switch (this) { | |
| case SlideDownSnackBarType.loading: | |
| return const SpinKitThreeBounce( | |
| color: Colors.white, | |
| semanticLabel: 'Loading', | |
| duration: Duration(milliseconds: 1300), | |
| size: 12, | |
| ); | |
| case SlideDownSnackBarType.failure: | |
| return const Icon( | |
| CupertinoIcons.exclamationmark_circle, | |
| semanticLabel: 'Failure', | |
| size: 14, | |
| color: Color(0xFFF03224), | |
| ); | |
| case SlideDownSnackBarType.success: | |
| return const Icon( | |
| Icons.check, | |
| semanticLabel: 'Success', | |
| size: 14, | |
| color: Color(0xFF46CB53), | |
| ); | |
| case SlideDownSnackBarType.plain: | |
| return null; | |
| } | |
| } | |
| } | |
| /// 세 개의 원이 교대로 커졌다 작아지는 애니메이션을 보여주는 위젯입니다. | |
| /// [color] 또는 [itemBuilder] 중 하나만 지정해야 합니다. | |
| class SpinKitThreeBounce extends StatefulWidget { | |
| const SpinKitThreeBounce({ | |
| super.key, | |
| this.color, | |
| this.size = 50.0, | |
| this.itemBuilder, | |
| this.duration = const Duration(milliseconds: 1400), | |
| this.controller, | |
| this.semanticLabel, | |
| }) : assert( | |
| (itemBuilder != null) ^ (color != null), | |
| 'You should specify either a itemBuilder or a color', | |
| ); | |
| /// 점들의 색상. [itemBuilder]와 함께 사용할 수 없습니다. | |
| final Color? color; | |
| /// 전체 너비의 절반 크기. 기본값은 50.0입니다. | |
| final double size; | |
| /// 커스텀 위젯 빌더. [color]와 함께 사용할 수 없습니다. | |
| final IndexedWidgetBuilder? itemBuilder; | |
| /// 애니메이션 주기. 기본값은 1400ms입니다. | |
| final Duration duration; | |
| /// 외부에서 제공하는 컨트롤러. 지정하지 않으면 내부에서 생성합니다. | |
| final AnimationController? controller; | |
| /// 위젯의 텍스트 설명을 제공합니다. | |
| final String? semanticLabel; | |
| @override | |
| SpinKitThreeBounceState createState() => SpinKitThreeBounceState(); | |
| } | |
| @visibleForTesting | |
| class SpinKitThreeBounceState extends State<SpinKitThreeBounce> with SingleTickerProviderStateMixin { | |
| @visibleForTesting | |
| late AnimationController controller; | |
| /// 내부에서 컨트롤러를 생성했는지 여부를 나타냅니다. | |
| @visibleForTesting | |
| late bool ownsController; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| if (widget.controller != null) { | |
| controller = widget.controller!; | |
| ownsController = false; | |
| } else { | |
| controller = AnimationController(vsync: this, duration: widget.duration); | |
| unawaited(controller.repeat()); | |
| ownsController = true; | |
| } | |
| } | |
| @override | |
| void didUpdateWidget(covariant SpinKitThreeBounce oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| // 외부 컨트롤러가 변경된 경우 | |
| if (oldWidget.controller != widget.controller) { | |
| if (ownsController) { | |
| controller.dispose(); | |
| } | |
| if (widget.controller != null) { | |
| controller = widget.controller!; | |
| ownsController = false; | |
| } else { | |
| controller = AnimationController(vsync: this, duration: widget.duration); | |
| unawaited(controller.repeat()); | |
| ownsController = true; | |
| } | |
| } | |
| // duration만 변경된 경우 (내부 컨트롤러일 때만 적용) | |
| else if (oldWidget.duration != widget.duration && ownsController) { | |
| controller.duration = widget.duration; | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| if (ownsController) { | |
| controller.dispose(); | |
| } | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Semantics( | |
| label: widget.semanticLabel, | |
| child: Center( | |
| child: SizedBox.fromSize( | |
| size: Size(widget.size * 2, widget.size), | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, | |
| children: List<Widget>.generate(3, (i) { | |
| return ScaleTransition( | |
| scale: DelayTween(begin: 0, end: 1, delay: i * .2).animate(controller), | |
| child: SizedBox.fromSize( | |
| size: Size.square(widget.size * 0.5), | |
| child: _buildItem(i), | |
| ), | |
| ); | |
| }), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildItem(int index) { | |
| if (widget.itemBuilder != null) { | |
| return widget.itemBuilder!(context, index); | |
| } | |
| return DecoratedBox( | |
| decoration: BoxDecoration( | |
| color: widget.color, | |
| shape: BoxShape.circle, | |
| ), | |
| ); | |
| } | |
| } | |
| /// [delay] 만큼 시작을 지연시키는 커스텀 Tween입니다. | |
| /// 애니메이션의 중간 값을 사인 곡선으로 표현합니다. | |
| class DelayTween extends Tween<double> { | |
| DelayTween({ | |
| super.begin, | |
| super.end, | |
| required this.delay, | |
| }); | |
| /// [delay]는 0~1 사이의 비율로, 전체 사이클 중 지연 비율을 나타냅니다. | |
| final double delay; | |
| @override | |
| double lerp(double t) => super.lerp((math.sin((t - delay) * 2 * math.pi) + 1) / 2); | |
| @override | |
| double evaluate(Animation<double> animation) => lerp(animation.value); | |
| } | |
| /// [delay] 만큼 처음 시작을 지연시키는 사인 곡선 기반 커브입니다. 독립적인 테스트를 위해 유지됩니다. | |
| class SineDelayCurve extends Curve { | |
| const SineDelayCurve(this.delay); | |
| /// [delay]는 0~1 사이의 비율로, 전체 사이클 중 지연 비율을 나타냅니다. | |
| final double delay; | |
| @override | |
| double transform(double t) { | |
| return (math.sin((t - delay) * 2 * math.pi) + 1) / 2; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment