When updating Music Discovery with Fusion for visionOS, I wanted to implement toast notifications similar to those I previously used on iOS. But, the different design paradigm of visionOS meant that having the notifications slide up from the bottom, as they do on iOS, did not make sense.
For those unaware, toast notifications are short-lived, non-intrusive messages that appear briefly on the screen to show some information or feedback to the user. You use them to indicate the success or failure of an action, display warnings, or provide general info. You design the toast to be noticeable but without interrupting the user or requiring them to perform any interaction.
In this post, I will share how I solved this by creating toast notifications as ornaments that appeared at the top of the window in visionOS.
Defining the Types of Ornament Notifications
I start with defining the types of toast. The OrnamentNotificationType
defines four types of notifications: error
, warning
, success
, and info
. Each type has a specific colour and icon to visually represent the notification:
public enum OrnamentNotificationType {
case error
case warning
case success
case info
}
extension OrnamentNotificationType {
public var color: Color {
switch self {
case .error: return Color.red
case .warning: return Color.orange
case .info: return Color.blue
case .success: return Color.green
}
}
public var icon: String {
switch self {
case .info: return "info.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .success: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
}
}
}
Creating an Ornament Notification Structure
I have a custom OrnamentNotification
structure which contains the information to display a toast notification as an ornament:
public struct OrnamentNotification: Identifiable, Equatable {
public var id = UUID()
public var title: String
public var message: String?
public var type: OrnamentNotificationType
public init(id: UUID = UUID(), title: String, message: String? = nil, type: OrnamentNotificationType) {
self.id = id
self.title = title
self.message = message
self.type = type
}
}
It has the following properties:
id
: A unique identifier to distinguish each notification instance.title
: A string for the main title or heading of the toast notification.message
: An optional string for additional details or information related to the notification.type
: The type that determines the visual style and icon of the notification.
Creating a Notification Protocol and Model
I then create OrnamentNotificationProtocol
to define a set of properties and methods that any object conforming to it must implement. It conforms to ObservableObject
protocol and has properties and methods for managing the visibility and behaviour of toast notifications:
public protocol OrnamentNotificationProtocol: ObservableObject {
var notification: OrnamentNotification? { get set }
var visibility: Visibility { get set }
var seconds: Int { get set }
func showNotification()
func dismissNotification()
}
notification
: AnOrnamentNotification
instance that represents the current toast notification to be displayed. I set tonil
when no notification is active.visibility
: The typeVisibility
that determines the visibility state of the toast notification with cases asautomatic
,visible
, orhidden
.seconds
: A value specifying the duration, in seconds, for which the toast notification should remain visible before it is automatically dismissed.showNotification()
: A method that triggers the display of the currentnotification
based on the specifiedvisibility
andseconds
values.dismissNotification()
: A method that manually dismisses the current toast notification, hiding it from view.
To provide a concrete implementation of the OrnamentNotificationProtocol
, I created the OrnamentNotificationModel
class. This handles the toast notifications for a particular screen:
public final class OrnamentNotificationModel: OrnamentNotificationProtocol {
@Published public var notification: OrnamentNotification?
@Published public var visibility: Visibility
public var seconds: Int
public init(notification: OrnamentNotification? = nil, visibility: Visibility = .hidden, seconds: Int = 2) {
self.notification = notification
self.visibility = visibility
self.seconds = seconds
}
public func showNotification() {
if notification != nil {
withAnimation(.easeInOut) {
visibility = .visible
}
dismissNotification()
}
}
public func dismissNotification() {
Task {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
self.notification = nil
visibility = .hidden
}
}
}
Let's break down the implementation:
- The
notification
property allows any changes to its value to trigger updates in the views that observe it. It represents the current toast notification to be displayed. - The
visibility
property determines the visibility state of the toast notification. It is used to control the animation and appearance of the notification view. - The
seconds
property specifies the duration, in seconds, for which the toast notification should remain visible before being automatically dismissed. - The
showNotification()
method checks if a notification exists and, if so, animates thevisibility
property to.visible
. I prefer.easeInOut
animation but you can try playing around with it. It then calls thedismissNotification()
method to schedule the automatic dismissal of the notification. - The
dismissNotification()
method automatically dismisses the toast notification after the specifiedseconds
duration and sets thenotification
tonil
and updates thevisibility
to.hidden
.
Creating the Toast Notification Views
I start with creating the component that displays the content of a single toast notification. This OrnamentNotificationItem
struct has the styling of the notification's icon, title, and message:
struct OrnamentNotificationItem: View {
var notification: OrnamentNotification
var body: some View {
HStack {
Image(systemName: notification.type.icon)
.foregroundStyle(notification.type.color)
VStack(alignment: .leading) {
Text(notification.title)
.font(.headline)
.foregroundColor(.primary)
if let message = notification.message {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
}
Then, I have the main view, OrnamentNotificationView
which is a generic view that accepts a ViewModel
type conforming to the OrnamentNotificationProtocol
:
struct OrnamentNotificationView<ViewModel: OrnamentNotificationProtocol>: View {
@ObservedObject var model: ViewModel
var body: some View {
Group {
if let notification = model.notification {
OrnamentNotificationItem(notification: notification)
}
}
.padding()
.padding(.horizontal)
}
}
This view conditionally renders the OrnamentNotificationItem
based on the presence of a notification in the ViewModel
.
Creating a Modifier for Toast Notification
Finally, to put it all together, I create an OrnamentNotificationModifier
that allows to easily attach a notification ornament to any view in the app:
public struct OrnamentNotificationModifier<ViewModel: OrnamentNotificationProtocol>: ViewModifier {
@ObservedObject var model: ViewModel
public func body(content: Content) -> some View {
content
.ornament(visibility: model.visibility, attachmentAnchor: .scene(.top), ornament: {
OrnamentNotificationView(model: model)
.opacity(model.notification != nil ? 1.0 : 0.0)
.glassBackgroundEffect()
})
.onChange(of: model.notification) { _ in
model.showNotification()
}
}
}
It uses the .ornament(visibility:attachmentAnchor:ornament:)
modifier to attach the OrnamentNotificationView
as an ornament to the content view.
The visibility
parameter is set based on the visibility
property of the ViewModel
, and the attachmentAnchor
is set to .scene(.top)
to position the notification at the top of the scene.
I have the .glassBackgroundEffect()
modifier is applied to the notification view to give it the native appealing glass-like background effect of visionOS!
To make it easier to use the OrnamentNotificationModifier
, I add an extension on View
:
extension View {
/// Adds a notification ornament to a view.
///
/// This modifier attaches an `OrnamentNotificationView` to the specified view, using the provided `OrnamentNotificationModel`.
///
/// Example:
/// ```swift
/// struct ContentView: View {
/// @StateObject private var notificationModel = OrnamentNotificationModel()
///
/// var body: some View {
/// SomeSpecificView()
/// .ornamentNotification(for: notificationModel)
/// }
/// }
/// ```
///
/// - Parameter model: The `OrnamentNotificationModel` to use for the notification ornament.
/// - Returns: A modified view with the attached notification ornament.
public func ornamentNotification(for model: some OrnamentNotificationProtocol) -> some View {
self.modifier(OrnamentNotificationModifier(model: model))
}
}
To use the ornamentNotification(for:)
modifier, I simply call it on the desired view and pass an instance of the notification model. The modifier will handle the creation and management of the OrnamentNotificationView
based on the state of the provided model.
Usage of Ornament Notification
Let us explore how I use the OrnamentNotification
and OrnamentNotificationModifier
in my Music Discovery with Fusion app, specifically in the station detail view where I want to display toast notifications when saving a playlist or encountering an error while playing a song:
struct StationDetailView<ViewModel: StationViewModelProtocol>: View {
#if os(visionOS)
@StateObject private var notificationModel = OrnamentNotificationModel()
#endif
// ... rest of the view code ...
private func addToLibrary(for song: Song) async {
do {
let success = try await MLibrary.addSong(id: song.id)
if success {
#if os(visionOS)
notificationModel.notification = OrnamentNotification(title: "Saved to Library! š", message: "", type: .success)
#endif
} else {
#if os(visionOS)
notificationModel.notification = OrnamentNotification(title: "Could not save to library. š„ŗ", message: "", type: .error)
#endif
}
} catch {
#if os(visionOS)
notificationModel.notification = OrnamentNotification(title: "Could not save to library. š„ŗ", message: error.localizedDescription, type: .error)
#endif
}
}
var body: some View {
// ... view content ...
#if os(visionOS)
.ornamentNotification(for: notificationModel)
#endif
}
}
The StationDetailView
has @StateObject
property notificationModel
of type OrnamentNotificationModel
which manages the state of the toast notifications.
Inside the addToLibrary(for:)
method, after attempting to save a song to the library, I set the notification for the success or failure cases. If the song is successfully saved, a success notification is created with the title "Saved to Library! š". If there is an error saving the song, an error notification is created with the title "Could not save to library. š„ŗ" and the localized description of the error as the message.
To attach the notification ornament to the view, I apply the .ornamentNotification(for:)
modifier to the view's body, passing the notificationModel
as the parameter.
Here is another example of how I use the ornamentNotification
modifier in the playSong(for:songs:)
method:
func playSong(for song: Song, songs: Songs) {
do {
try nowPlayingViewModel.play(with: song, for: songs)
} catch {
#if os(visionOS)
notificationModel.notification = OrnamentNotification(title: "Could not play the song. š", message: error.localizedDescription, type: .error)
#endif
}
}
Conclusion
Adding toast notifications to Music Discovery with Fusion was both challenging and fun. I did not think of using ornaments before but ended up with some pretty notifications, thanks to the glass background effect.
If you have any alternative approaches, I would love to hear from you. Feel free to reach out to me on X (formerly Twitter) at @rudrankriyam.
Happy coding spatially!