Skip to content

Instantly share code, notes, and snippets.

@AndrewDongminYoo
Created November 12, 2025 03:10
Show Gist options
  • Select an option

  • Save AndrewDongminYoo/b5bd25feaf20ab714ba524e1094477c2 to your computer and use it in GitHub Desktop.

Select an option

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.
// 🎯 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