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: errorwarningsuccess, 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:

  1. id: A unique identifier to distinguish each notification instance.
  2. title: A string for the main title or heading of the toast notification.
  3. message: An optional string for additional details or information related to the notification.
  4. 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: An OrnamentNotification instance that represents the current toast notification to be displayed. I set to nil when no notification is active.
  • visibility: The type Visibility that determines the visibility state of the toast notification with cases as automaticvisible, or hidden.
  • 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 current notification based on the specified visibility and seconds 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:

  1. 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.
  2. The visibility property determines the visibility state of the toast notification. It is used to control the animation and appearance of the notification view.
  3. The seconds property specifies the duration, in seconds, for which the toast notification should remain visible before being automatically dismissed.
  4. The showNotification() method checks if a notification exists and, if so, animates the visibility property to .visible. I prefer .easeInOut animation but you can try playing around with it. It then calls the dismissNotification() method to schedule the automatic dismissal of the notification.
  5. The dismissNotification() method automatically dismisses the toast notification after the specified seconds duration and sets the notification to nil and updates the visibility 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
  }
}
0:00
/0:06

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!

Runway sponsorship.

Death by a thousand branch cuts

Or use Runway for your mobile release management instead.

Tagged in: