Files
iOS/Sources/Shared/Toast/ToastManager.swift
Bruno Pantaleão Gonçalves 6b3419ea23 Display dynamic island indication when db is updating (#4230)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->
<img width="1796" height="804" alt="CleanShot 2026-01-16 at 13 11 31@2x"
src="https://github.com/user-attachments/assets/4c0be252-f3b6-4ff5-b27e-8b11f19eaee4"
/>

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2026-01-16 12:50:22 +00:00

148 lines
5.2 KiB
Swift

import SFSafeSymbols
import SwiftUI
/// A manager class that provides imperative control over toast presentation.
///
/// Use this class to show and hide toasts from UIKit contexts where SwiftUI view modifiers
/// are not applicable. The manager maintains a shared overlay window that displays toasts
/// using the Dynamic Island-style animation.
///
/// Example usage:
/// ```swift
/// if #available(iOS 18, *) {
/// ToastManager.shared.show(
/// id: "my-toast",
/// symbol: .checkmarkSealFill,
/// symbolForegroundStyle: (.white, .green),
/// title: "Success",
/// message: "Operation completed"
/// )
///
/// // Later, to hide:
/// ToastManager.shared.hide(id: "my-toast")
/// }
/// ```
@available(iOS 18, *)
@MainActor
public final class ToastManager {
/// The shared singleton instance of the toast manager.
public static let shared = ToastManager()
public static var toastComponentVersion = 1
private var overlayWindow: PassThroughWindow?
private var overlayController: ToastHostingController?
private var autoDismissTask: Task<Void, Never>?
public init() {}
/// Shows a toast with the specified parameters.
///
/// - Parameters:
/// - id: A unique identifier for the toast. Used to hide a specific toast later.
/// - symbol: The SF Symbol to display in the toast.
/// - symbolFont: The font for the symbol. Defaults to system size 35.
/// - symbolForegroundStyle: A tuple of colors for the symbol's primary and secondary styles.
/// - title: The title text displayed in the toast.
/// - message: The message text displayed below the title.
/// - duration: Optional duration in seconds after which the toast auto-dismisses.
/// Pass `nil` for a permanent toast that must be dismissed manually.
public func show(
id: String,
symbol: String,
symbolFont: Font = .system(size: 35),
symbolForegroundStyle: (Color, Color),
title: String,
message: String? = nil,
duration: TimeInterval? = nil
) {
let toast = Toast(
id: id,
symbol: symbol,
symbolFont: symbolFont,
symbolForegroundStyle: symbolForegroundStyle,
title: title,
message: message ?? ""
)
show(toast: toast, duration: duration)
}
/// Shows the specified toast.
///
/// - Parameters:
/// - toast: The toast to display.
/// - duration: Optional duration in seconds after which the toast auto-dismisses.
/// Pass `nil` for a permanent toast that must be dismissed manually.
public func show(toast: Toast, duration: TimeInterval? = nil) {
// Cancel any pending auto-dismiss
autoDismissTask?.cancel()
autoDismissTask = nil
// Create or get the overlay window
ensureOverlayWindow()
guard let overlayWindow else { return }
// Set the toast and present it
overlayWindow.toast = toast
overlayWindow.isPresented = true
overlayController?.isStatusBarHidden = true
// Schedule auto-dismiss if duration is provided
if let duration {
autoDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration))
guard !Task.isCancelled else { return }
self?.hide(id: toast.id)
}
}
}
/// Hides the toast with the specified ID.
///
/// - Parameter id: The identifier of the toast to hide.
public func hide(id: String) {
guard let overlayWindow, overlayWindow.toast?.id == id else { return }
hideCurrentToast()
}
/// Hides any currently displayed toast.
public func hideCurrentToast() {
autoDismissTask?.cancel()
autoDismissTask = nil
overlayWindow?.isPresented = false
overlayController?.isStatusBarHidden = false
}
// MARK: - Private Methods
private func ensureOverlayWindow() {
#if os(iOS)
// Try to find an existing window first
if let windowScene = Current.application?().connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) {
if let existingWindow = windowScene.windows.first(where: { $0.tag == 1009 }) as? PassThroughWindow {
overlayWindow = existingWindow
overlayController = existingWindow.rootViewController as? ToastHostingController
return
}
// Create a new overlay window
let window = PassThroughWindow(windowScene: windowScene)
window.backgroundColor = .clear
window.isHidden = false
window.isUserInteractionEnabled = true
window.tag = 1009
window.windowLevel = .statusBar + 1
let hostingController = ToastHostingController(rootView: ToastView(window: window))
hostingController.view.backgroundColor = .clear
window.rootViewController = hostingController
overlayWindow = window
overlayController = hostingController
}
#endif
}
}