Flutter Fade & Scale Animations for Interactive Toast Notifications
Toast notifications are a ubiquitous component in modern applications, providing non-intrusive feedback to users. While a simple static toast serves its purpose, incorporating smooth animations significantly elevates the user experience. This article delves into creating interactive toast notifications in Flutter, utilizing fade and scale animations to deliver a polished and engaging visual feedback mechanism.
We will build a custom toast solution that features:
- Smooth entry and exit animations (fade and scale).
- Automatic dismissal after a set duration.
- Interactive dismissal by tapping the toast.
- Display over existing content using
OverlayEntry.
Understanding the Core Components
Before diving into the code, let's briefly understand the key Flutter concepts we'll be leveraging:
OverlayEntry: Allows us to insert a widget tree on top of all other widgets in theOverlaywidget tree. This is perfect for displaying toasts that need to appear above the regular app content.StatefulWidgetwithSingleTickerProviderStateMixin: Our custom toast widget will be stateful to manage its animation lifecycle. The mixin provides aTickerneeded forAnimationController.AnimationController: Manages the animation's playback (e.g., forward, reverse, duration).TweenandCurvedAnimation: Define the range of values for our animation (e.g., 0.0 to 1.0 for opacity) and the curve of the animation (e.g.,Curves.easeOut,Curves.bounceOut).FadeTransitionandScaleTransition: Widgets that automatically apply opacity and scale transformations to their children based on anAnimationobject.GestureDetector: Enables user interaction, such as tapping, to dismiss the toast early.
Crafting the Interactive Animated Toast Widget
First, let's create a custom widget, CustomToast, that encapsulates our toast's appearance, animation logic, and interactivity. This widget will manage its own show and hide animations and provide a callback when it's fully dismissed.
import 'package:flutter/material.dart';
class CustomToast extends StatefulWidget {
final String message;
final Duration duration;
final VoidCallback onDismissed;
const CustomToast({
Key? key,
required this.message,
this.duration = const Duration(seconds: 3),
required this.onDismissed,
}) : super(key: key);
@override
_CustomToastState createState() => _CustomToastState();
}
class _CustomToastState extends State with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation _fadeAnimation;
late Animation _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300), // Animation duration
);
// Fade animation from transparent to opaque
_fadeAnimation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// Scale animation from slightly smaller to full size, with a bounce effect
_scaleAnimation = Tween(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.bounceOut),
);
// Start the entry animation
_controller.forward();
// Set a timer for automatic dismissal
Future.delayed(widget.duration, () {
if (mounted) { // Ensure the widget is still in the tree
_controller.reverse().then((_) {
widget.onDismissed(); // Notify parent that toast is fully dismissed
});
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// Method to manually dismiss the toast
void _dismissToast() {
if (mounted) {
_controller.reverse().then((_) {
widget.onDismissed();
});
}
}
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 50.0, // Position the toast near the bottom
left: 20.0,
right: 20.0,
child: GestureDetector(
onTap: _dismissToast, // Make the toast interactively dismissible
child: FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: Material(
color: Colors.transparent, // Important for consistent styling and hit testing
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(25.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, color: Colors.white),
const SizedBox(width: 8.0),
Expanded(
child: Text(
widget.message,
style: const TextStyle(color: Colors.white, fontSize: 16.0),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
),
),
);
}
}
Implementing a Toast Service with OverlayEntry
To display our CustomToast over all other widgets and manage its lifecycle (showing and dismissing), we'll implement a simple static service class. This service will handle the creation and removal of OverlayEntry instances.
import 'package:flutter/material.dart';
// Assuming CustomToast is in a separate file (e.g., 'custom_toast.dart')
// import 'custom_toast.dart';
class ToastService {
static OverlayEntry? _currentOverlayEntry;
static void show(BuildContext context, String message, {Duration duration = const Duration(seconds: 3)}) {
// Dismiss any existing toast first to prevent multiple toasts
dismiss();
_currentOverlayEntry = OverlayEntry(
builder: (context) => CustomToast(
message: message,
duration: duration,
onDismissed: () {
// This callback is triggered when CustomToast's exit animation completes
dismiss();
},
),
);
// Insert the OverlayEntry into the Overlay
Overlay.of(context).insert(_currentOverlayEntry!);
}
static void dismiss() {
// Remove the OverlayEntry if it exists
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
}
}
Using the Toast Notification Service
Finally, integrating the toast service into your application is straightforward. You can call ToastService.show() from anywhere you have access to a BuildContext.
import 'package:flutter/material.dart';
// Assuming ToastService is in a separate file (e.g., 'toast_service.dart')
// import 'toast_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Interactive Toast',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Interactive Toast Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
ToastService.show(
context,
'This is a success message! Tap to dismiss early.',
duration: const Duration(seconds: 4)
);
},
child: const Text('Show Success Toast'),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
ToastService.show(
context,
'A warning occurred. Please review your input carefully.',
duration: const Duration(seconds: 5)
);
},
child: const Text('Show Warning Toast'),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
ToastService.show(
context,
'User settings updated!',
duration: const Duration(seconds: 2)
);
},
child: const Text('Show Quick Toast'),
),
],
),
),
);
}
}
Conclusion
By combining Flutter's powerful animation framework with OverlayEntry, we've successfully created an interactive toast notification system that is both visually appealing and user-friendly. The fade and scale animations provide a fluid entry and exit, while the ability to dismiss the toast with a tap enhances the user's control over their interface. This pattern can be extended further to include different toast types (success, error, info), custom icons, and more complex animation sequences, allowing for highly personalized and engaging user feedback across your Flutter applications.