Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Stacked Expandable Notifications #170

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
239 changes: 130 additions & 109 deletions lib/src/core/toastification_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,29 @@ class ToastificationManager {

OverlayEntry? _overlayEntry;

/// this key is attached to [AnimatedList] so we can add or remove items using it.
final _listGlobalKey = GlobalKey<AnimatedListState>();

/// this is the list of items that are currently shown
/// This is the list of items that are currently shown
/// if the list is empty, the overlay entry will be removed
final List<ToastificationItem> _notifications = [];
final ValueNotifier<List<ToastificationItem>> _notifications =
ValueNotifier<List<ToastificationItem>>([]);

/// this is the delay for showing the overlay entry
/// This is the delay for showing the overlay entry
/// We need this delay because we want to show the item animation after
/// the overlay created
/// the overlay is created
///
/// When we want to show first toast, we need to wait for the overlay to be created
/// and then show the toast item.
final _createOverlayDelay = const Duration(milliseconds: 100);

/// this is the delay for removing the overlay entry
/// This is the delay for removing the overlay entry
///
/// when we want to remove the last toast, we need to wait for the animation
/// When we want to remove the last toast, we need to wait for the animation
/// to be completed and then remove the overlay.
final _removeOverlayDelay = const Duration(milliseconds: 50);

/// Shows a [ToastificationItem] with the given [builder] and [animationBuilder].
///
/// if the [_notifications] list is empty, we will create the [_overlayEntry]
/// otherwise we will just add the [item] to the [_notifications] list.
/// If the [_notifications] list is empty, we will create the [_overlayEntry]
/// otherwise, we will just add the [item] to the [_notifications] list.
ToastificationItem showCustom({
required OverlayState overlayState,
required ToastificationBuilder builder,
Expand Down Expand Up @@ -75,16 +73,23 @@ class ToastificationManager {
Future.delayed(
delay,
() {
_notifications.insert(0, item);
// _notifications.insert(0, item);

_listGlobalKey.currentState?.insertItem(
0,
duration: _createAnimationDuration(item),
);
_notifications.value = [item, ..._notifications.value];

// _listGlobalKey.currentState?.insertItem(
// 0,
// duration: _createAnimationDuration(item),
// );

while (_notifications.length > config.maxToastLimit) {
// while (_notifications.length > config.maxToastLimit) {
// dismissLast();
// }

while (_notifications.value.length > config.maxToastLimit) {
dismissLast();
}

},
);

Expand All @@ -94,73 +99,39 @@ class ToastificationManager {
/// Finds the [ToastificationItem] with the given [id].
ToastificationItem? findToastificationItem(String id) {
try {
return _notifications
return _notifications.value
.firstWhereOrNull((notification) => notification.id == id);
} catch (e) {
return null;
}
}

/// using this method you can remove a notification item
/// if there is no notification in the notification list,
/// Using this method you can remove a notification item
/// If there is no notification in the notification list,
/// we will remove the overlay entry
///
/// if the [showRemoveAnimation] is true, we will show the remove animation
/// If the [showRemoveAnimation] is true, we will show the remove animation
/// of the [notification] item.
/// otherwise we will remove the notification without showing any animation.
/// this is useful when you want to remove the notification manually,
/// Otherwise, we will remove the notification without showing any animation.
/// This is useful when you want to remove the notification manually,
/// like when you have some [Dismissible] widget
void dismiss(
ToastificationItem notification, {
bool showRemoveAnimation = true,
}) {
final index = _notifications.indexOf(notification);
// print("Toastification Manager Dismiss Notifications: $_notifications");
if (index != -1) {
notification = _notifications[index];
final updatedList = List<ToastificationItem>.from(_notifications.value);
if (updatedList.remove(notification)) {
_notifications.value = updatedList;

if (notification.isRunning) {
notification.stop();
}

final removedItem = _notifications.removeAt(index);

/// if the [showRemoveAnimation] is true, we will show the remove animation
/// of the notification.
if (showRemoveAnimation) {
_listGlobalKey.currentState?.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return ToastHolderWidget(
item: removedItem,
animation: animation,
alignment: alignment,
transformerBuilder: _toastAnimationBuilder(removedItem),
);
},
duration: _createAnimationDuration(removedItem),
);

/// if the [showRemoveAnimation] is false, we will remove the notification
/// without showing the remove animation.
} else {
_listGlobalKey.currentState?.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return const SizedBox.shrink();
},
);
}

/// we will remove the [_overlayEntry] if there are no notifications
/// We will remove the [_overlayEntry] if there are no notifications
/// We need to check if the _notifications list is empty twice.
/// To make sure after the delay, there are no new notifications added.
if (_notifications.isEmpty) {
if (_notifications.value.isEmpty) {
Future.delayed(
(removedItem.animationDuration ?? config.animationDuration) +
(notification.animationDuration ?? config.animationDuration) +
_removeOverlayDelay,
() {
if (_notifications.isEmpty) {
if (_notifications.value.isEmpty) {
_overlayEntry?.remove();
_overlayEntry = null;
}
Expand All @@ -174,83 +145,135 @@ class ToastificationManager {
/// The [delayForAnimation] parameter is optional and defaults to true.
/// When it is true, it adds a delay for better animation.
void dismissAll({bool delayForAnimation = true}) async {
// Creates a new list cloneList that has all the notifications from the _notifications list, but in reverse order.
final cloneList = _notifications.toList(growable: false).reversed;
final cloneList = _notifications.value.toList(growable: false).reversed;

// For each cloned "toastItem" notification in "cloneList",
// we will remove it and then pause for a duration if delayForAnimation is true.
for (final toastItem in cloneList) {
/// If the item is still in the [_notification] list, we will remove it
if (findToastificationItem(toastItem.id) != null) {
// Dismiss the current notification item
dismiss(toastItem);

// If delayForAnimation is true, wait for 150ms before proceeding to the next item
if (delayForAnimation) {
await Future.delayed(const Duration(milliseconds: 150));
}
}
}
}

/// remove the first notification in the list.
/// Remove the first notification in the list.
void dismissFirst() {
dismiss(_notifications.first);
if (_notifications.value.isNotEmpty) {
dismiss(_notifications.value.first);
}
}

/// remove the last notification in the list.
/// Remove the last notification in the list.
void dismissLast() {
dismiss(_notifications.last);
if (_notifications.value.isNotEmpty) {
dismiss(_notifications.value.last);
}
}

void _createNotificationHolder(OverlayState overlay) {
_overlayEntry = _createOverlayEntry();
overlay.insert(_overlayEntry!);
}

/// create a [OverlayEntry] as holder of the notifications
/// Create a [OverlayEntry] as holder of the notifications
OverlayEntry _createOverlayEntry() {
ValueNotifier<bool> isHovering = ValueNotifier<bool>(false);

// Handle hover state changes to pause/start timers
isHovering.addListener(() {
for (var item in _notifications.value) {
if (item.hasTimer) {
if (isHovering.value) {
item.pause();
} else {
item.start();
}
}
}
});

return OverlayEntry(
opaque: false,
builder: (context) {
Widget overlay = Align(
return Align(
alignment: alignment,
child: Container(
margin: _marginBuilder(context, alignment, config),
constraints: BoxConstraints.tightFor(
width: config.itemWidth,
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
child: AnimatedList(
key: _listGlobalKey,
clipBehavior: config.clipBehavior,
initialItemCount: _notifications.length,
reverse: alignment.y >= 0,
primary: true,
shrinkWrap: true,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final item = _notifications[index];

return ToastHolderWidget(
item: item,
animation: animation,
alignment: alignment,
transformerBuilder: _toastAnimationBuilder(item),
child: MouseRegion(
onEnter: (_) => isHovering.value = true,
onExit: (_) => isHovering.value = false,
child: Container(
margin: _marginBuilder(context, alignment, config),
constraints: BoxConstraints.tightFor(width: config.itemWidth),
child: AnimatedBuilder(
animation: Listenable.merge([_notifications, isHovering]),
builder: (context, child) {
const int maxToasts = 5;
const double maxOpacity = 1.0;
const double minOpacity = 0.5;
const double expandedOffset = 90.0;
const double collapsedOffset = 10.0;

final notifications = _notifications.value;
final isHoveringValue = isHovering.value;

final startIndex = notifications.length > maxToasts
? notifications.length - maxToasts
: 0;
final visibleNotifications =
notifications.sublist(startIndex).reversed.toList();

return Stack(
alignment: Alignment.topCenter,
children:
List.generate(visibleNotifications.length, (index) {
final item = visibleNotifications[index];
final reverseIndex =
visibleNotifications.length - index - 1;

final opacity = isHoveringValue || reverseIndex == 0
? maxOpacity
: maxOpacity -
(reverseIndex /
(visibleNotifications.length - 1)) *
(maxOpacity - minOpacity);

final targetOffset = reverseIndex *
(isHoveringValue ? expandedOffset : collapsedOffset);

return AnimatedPositioned(
key: ValueKey(item.id),
duration: _createAnimationDuration(item),
curve: Curves.easeInOut,
top: targetOffset,
left: 0,
right: 0,
child: AnimatedOpacity(
opacity: opacity,
duration: _createAnimationDuration(item),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: _createAnimationDuration(item),
builder: (context, animationValue, child) {
return ToastHolderWidget(
item: item,
animation:
AlwaysStoppedAnimation(animationValue),
alignment: alignment,
transformerBuilder:
_toastAnimationBuilder(item),
);
},
),
),
);
}),
);
},
),
),
),
);

return overlay;
},
);
}
Expand All @@ -266,8 +289,6 @@ class ToastificationManager {
marginValue = marginValue.add(MediaQuery.of(context).viewInsets);
}

/// Add the MediaQuery viewPadding as margin so other widgets behind the toastification overlay
/// will be touchable and not covered by the toastification overlay.
return marginValue.add(MediaQuery.of(context).viewPadding);
}

Expand Down