├── .gitattributes ├── .vscode └── settings.json ├── ToastsExampleApp.swiftpm ├── MyApp.swift ├── Package.resolved ├── Package.swift └── ContentView.swift ├── .gitignore ├── Sources └── Toast │ ├── ToastPosition.swift │ ├── Internals │ ├── TransformModifier.swift │ ├── Toast.swift │ ├── Color+DarkMode.swift │ ├── LoadingView.swift │ ├── Accessibility.swift │ ├── ToastRootView.swift │ ├── Backports.swift │ ├── ToastInteractingView.swift │ ├── ToastManager.swift │ └── ToastView.swift │ ├── ToastValue.swift │ ├── PresentToastAction.swift │ └── InstallToastModifier.swift ├── Package.resolved ├── Package.swift ├── LICENSE.md ├── Tests └── ToastManagerTests │ └── ToastManagerTests.swift └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": true, 4 | "editor.detectIndentation": false 5 | } 6 | -------------------------------------------------------------------------------- /ToastsExampleApp.swiftpm/MyApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct MyApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | .installToast(position: .bottom) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .swiftpm/ 10 | buildServer.json 11 | /ToastsExampleApp.swiftpm/.build 12 | -------------------------------------------------------------------------------- /Sources/Toast/ToastPosition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the vertical position where toasts will appear on screen. 4 | public enum ToastPosition { 5 | /// Toast appears at the top of the screen. 6 | case top 7 | /// Toast appears at the bottom of the screen. 8 | case bottom 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/TransformModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct TransformModifier: ViewModifier { 4 | var yOffset: CGFloat 5 | var scale: CGFloat 6 | var opacity: Double 7 | 8 | func body(content: Content) -> some View { 9 | content 10 | .opacity(opacity) 11 | .scaleEffect(scale) 12 | .offset(y: yOffset) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swiftui-window-overlay", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/sunghyun-k/swiftui-window-overlay.git", 7 | "state" : { 8 | "revision" : "46739300713772a62a3bf31d77f8f23ba9cf61f7", 9 | "version" : "1.0.2" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/Toast.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | @dynamicMemberLookup 5 | internal final class ToastModel: ObservableObject, Identifiable { 6 | @Published internal var value: ToastValue 7 | internal init(value: ToastValue) { 8 | self.value = value 9 | } 10 | 11 | internal subscript(dynamicMember keyPath: WritableKeyPath) -> V { 12 | get { value[keyPath: keyPath] } 13 | set { value[keyPath: keyPath] = newValue } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ToastsExampleApp.swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "1cc435beaa5b18b7288bafa8d8ed2458c4da3b9e0be069f5651706980c1458e8", 3 | "pins" : [ 4 | { 5 | "identity" : "swiftui-window-overlay", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sunghyun-k/swiftui-window-overlay.git", 8 | "state" : { 9 | "revision" : "46739300713772a62a3bf31d77f8f23ba9cf61f7", 10 | "version" : "1.0.2" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/Color+DarkMode.swift: -------------------------------------------------------------------------------- 1 | import struct SwiftUI.Color 2 | import class UIKit.UIColor 3 | 4 | extension UIColor { 5 | internal convenience init(light: UIColor, dark: UIColor) { 6 | self.init { $0.userInterfaceStyle == .dark ? dark : light } 7 | } 8 | } 9 | 10 | extension Color { 11 | internal init(light: Color, dark: Color) { 12 | self.init(_uiColor: UIColor.init(light: UIColor(light), dark: UIColor(dark))) 13 | } 14 | } 15 | 16 | extension Color { 17 | internal static let toastBackground: Color = Color(light: .white, dark: Color(white: 0.12)) 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/LoadingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct LoadingView: View { 4 | @State private var toggle = false 5 | var body: some View { 6 | ZStack { 7 | Circle() 8 | .stroke(Color.primary.opacity(0.2), lineWidth: 2) 9 | 10 | Circle() 11 | .trim(from: 0.0, to: 0.3) 12 | .stroke(Color.primary, lineWidth: 2) 13 | .rotationEffect(.degrees(toggle ? 360 : 0)) 14 | } 15 | .frame(width: 16, height: 16) 16 | .onAppear { 17 | withAnimation(.linear.repeatForever(autoreverses: false).speed(0.3)) { 18 | toggle = true 19 | } 20 | } 21 | } 22 | } 23 | 24 | #Preview { 25 | LoadingView() 26 | } 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "swiftui-toasts", 6 | platforms: [.iOS(.v14)], 7 | products: [ 8 | .library( 9 | name: "Toasts", 10 | targets: ["Toasts"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/sunghyun-k/swiftui-window-overlay.git", from: "1.0.2") 14 | ], 15 | targets: [ 16 | .target( 17 | name: "Toasts", 18 | dependencies: [ 19 | .product(name: "WindowOverlay", package: "swiftui-window-overlay") 20 | ] 21 | ), 22 | .testTarget( 23 | name: "ToastManagerTests", 24 | dependencies: ["Toasts"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/Accessibility.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | func announceToAccessibility(_ message: String) { 5 | guard !message.isEmpty, UIAccessibility.isVoiceOverRunning else { return } 6 | 7 | let attributedMessage = createAttributedAccessibilityMessage(message) 8 | UIAccessibility.post(notification: .announcement, argument: attributedMessage) 9 | } 10 | 11 | private func createAttributedAccessibilityMessage(_ message: String) -> NSAttributedString { 12 | var attributes: [NSAttributedString.Key: Any] = [ 13 | .accessibilitySpeechQueueAnnouncement: false 14 | ] 15 | if #available(iOS 17.0, *) { 16 | attributes[.accessibilitySpeechAnnouncementPriority] = UIAccessibilityPriority.high.rawValue 17 | } 18 | return NSAttributedString(string: message, attributes: attributes) 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sunghyun Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ToastsExampleApp.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import AppleProductTypes 8 | import PackageDescription 9 | 10 | let package = Package( 11 | name: "ToastsExampleApp", 12 | platforms: [ 13 | .iOS("18.0") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "ToastsExampleApp", 18 | targets: ["AppModule"], 19 | bundleIdentifier: "sample.ToastsExampleApp", 20 | displayVersion: "1.0", 21 | bundleVersion: "1", 22 | appIcon: .placeholder(icon: .bird), 23 | accentColor: .presetColor(.indigo), 24 | supportedDeviceFamilies: [ 25 | .pad, 26 | .phone, 27 | ], 28 | supportedInterfaceOrientations: [ 29 | .portrait, 30 | .landscapeRight, 31 | .landscapeLeft, 32 | .portraitUpsideDown(.when(deviceFamilies: [.pad])), 33 | ] 34 | ) 35 | ], 36 | dependencies: [ 37 | .package(name: "swiftui-toasts", path: "..") 38 | ], 39 | targets: [ 40 | .executableTarget( 41 | name: "AppModule", 42 | dependencies: [ 43 | .product(name: "Toasts", package: "swiftui-toasts") 44 | ], 45 | path: "." 46 | ) 47 | ], 48 | swiftLanguageVersions: [.version("6")] 49 | ) 50 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/ToastRootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct ToastRootView: View { 4 | 5 | @ObservedObject var manager: ToastManager 6 | 7 | var body: some View { 8 | main 9 | .onAppear(perform: manager.onAppear) 10 | } 11 | 12 | @ViewBuilder 13 | private var main: some View { 14 | let isTop = manager.position == .top 15 | VStack(spacing: 8) { 16 | if !isTop { Spacer() } 17 | 18 | let models = isTop ? manager.models.reversed() : manager.models 19 | ForEach(manager.isAppeared ? models : []) { model in 20 | ToastInteractingView(model: model, manager: manager) 21 | .transition( 22 | .modifier( 23 | active: TransformModifier( 24 | yOffset: isTop ? -96 : 96, 25 | scale: 0.5, 26 | opacity: 0.0 27 | ), 28 | identity: TransformModifier( 29 | yOffset: 0, 30 | scale: 1.0, 31 | opacity: 1.0 32 | ) 33 | ) 34 | ) 35 | } 36 | 37 | if isTop { Spacer() } 38 | } 39 | .animation( 40 | .spring(duration: removalAnimationDuration), 41 | value: Tuple(count: manager.models.count, isAppeared: manager.isAppeared) 42 | ) 43 | .padding() 44 | .padding(manager.safeAreaInsets) 45 | .animation(.spring(duration: removalAnimationDuration), value: manager.safeAreaInsets) 46 | .ignoresSafeArea() 47 | } 48 | } 49 | 50 | private struct Tuple: Equatable { 51 | var count: Int 52 | var isAppeared: Bool 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/Backports.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | @ViewBuilder 5 | nonisolated internal func _background( 6 | alignment: Alignment = .center, 7 | @ViewBuilder content: () -> some View 8 | ) -> some View { 9 | if #available(iOS 15.0, *) { 10 | self.background(alignment: alignment, content: content) 11 | } else { 12 | self.background(content(), alignment: alignment) 13 | } 14 | } 15 | 16 | @ViewBuilder 17 | internal func _foregroundColor(_ color: Color) -> some View { 18 | if #available(iOS 15.0, *) { 19 | self.foregroundStyle(color) 20 | } else { 21 | self.foregroundColor(color) 22 | } 23 | } 24 | 25 | @ViewBuilder 26 | internal func _onChange( 27 | of value: V, 28 | initial: Bool = false, 29 | _ action: @escaping (_ oldValue: V, _ newValue: V) -> Void 30 | ) -> some View { 31 | if #available(iOS 17.0, *) { 32 | self.onChange(of: value, initial: initial, action) 33 | } else { 34 | self 35 | .onAppear { 36 | if initial { action(value, value) } 37 | } 38 | .onChange(of: value) { [oldValue = value] newValue in 39 | action(oldValue, newValue) 40 | } 41 | } 42 | } 43 | } 44 | 45 | extension Task where Success == Never, Failure == Never { 46 | static func sleep(seconds: Double) async throws { 47 | if #available(iOS 16.0, *) { 48 | try await sleep(for: .seconds(seconds)) 49 | } else { 50 | try await sleep(nanoseconds: UInt64(seconds * 1000) * NSEC_PER_MSEC) 51 | } 52 | } 53 | } 54 | 55 | extension Color { 56 | internal init(_uiColor uiColor: UIColor) { 57 | if #available(iOS 15.0, *) { 58 | self.init(uiColor: uiColor) 59 | } else { 60 | self.init(uiColor) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/ToastInteractingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct ToastInteractingView: View { 4 | 5 | @ObservedObject var model: ToastModel 6 | let manager: ToastManager 7 | @GestureState private var yOffset: CGFloat? 8 | @State private var dismissTask: Task? 9 | 10 | private var isDragging: Bool { yOffset != nil } 11 | 12 | var body: some View { 13 | main 14 | ._onChange(of: isDragging) { _, newValue in 15 | if newValue { 16 | dismissTask?.cancel() 17 | dismissTask = nil 18 | } 19 | } 20 | ._onChange(of: model.duration == nil, initial: true) { _, newValue in 21 | startDismissTask() 22 | } 23 | } 24 | 25 | @MainActor 26 | private var main: some View { 27 | ToastView(model: model) 28 | .offset(y: yOffset ?? 0) 29 | .gesture(dragGesture) 30 | .animation(.spring, value: isDragging) 31 | } 32 | 33 | @MainActor 34 | private var dragGesture: some Gesture { 35 | DragGesture(minimumDistance: 0) 36 | .updating($yOffset) { value, state, _ in 37 | let translation = value.translation.height 38 | if model.duration == nil { 39 | state = translation * 0.5 40 | } else { 41 | let isTopPosition = manager.position == .top 42 | let shouldReduceTranslation = (isTopPosition && translation > 0) || (!isTopPosition && translation < 0) 43 | state = shouldReduceTranslation ? translation * 0.5 : translation 44 | } 45 | } 46 | .onEnded { value in 47 | if model.duration == nil { return } 48 | let threshold: CGFloat = 48 / 2 49 | let draggedAmount = manager.position == .top ? -value.translation.height : value.translation.height 50 | if draggedAmount > threshold { 51 | manager.remove(model) 52 | } else { 53 | startDismissTask() 54 | } 55 | } 56 | } 57 | 58 | private func startDismissTask() { 59 | dismissTask?.cancel() 60 | dismissTask = Task { 61 | await manager.startRemovalTask(for: model) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Toast/ToastValue.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Represents a toast notification with customizable content and behavior. 5 | public struct ToastValue { 6 | internal var icon: AnyView? 7 | internal var message: String 8 | internal var button: ToastButton? 9 | /// If nil, the toast will persist and not disappear. Used when displaying a loading toast. 10 | internal var duration: TimeInterval? 11 | 12 | /// Creates a new toast with the specified content and behavior. 13 | /// 14 | /// - Parameters: 15 | /// - icon: An optional view to display as an icon in the toast. 16 | /// - message: The text content of the toast. 17 | /// - button: An optional action button to display in the toast. 18 | /// - duration: How long the toast should be displayed before automatically dismissing, in seconds. Clamped between 0 and 10 seconds. Default is 3.0. 19 | public init( 20 | icon: (any View)? = nil, 21 | message: String, 22 | button: ToastButton? = nil, 23 | duration: TimeInterval = 3.0 24 | ) { 25 | self.icon = icon.map { AnyView($0) } 26 | self.message = message 27 | self.button = button 28 | self.duration = min(max(0, duration), 10) 29 | } 30 | @_disfavoredOverload 31 | internal init( 32 | icon: (any View)? = nil, 33 | message: String, 34 | button: ToastButton? = nil, 35 | duration: TimeInterval? = nil 36 | ) { 37 | self.icon = icon.map { AnyView($0) } 38 | self.message = message 39 | self.button = button 40 | self.duration = duration 41 | } 42 | } 43 | 44 | /// Represents an action button that can be displayed within a toast. 45 | public struct ToastButton { 46 | /// The text to display on the button. 47 | public var title: String 48 | 49 | /// The color of the button text. 50 | public var color: Color 51 | 52 | /// The action to perform when the button is tapped. 53 | public var action: () -> Void 54 | 55 | /// Creates a new toast button with the specified title, color, and action. 56 | /// 57 | /// - Parameters: 58 | /// - title: The text to display on the button. 59 | /// - color: The color of the button text. Default is `.primary`. 60 | /// - action: The closure to execute when the button is tapped. 61 | public init( 62 | title: String, 63 | color: Color = .primary, 64 | action: @escaping () -> Void 65 | ) { 66 | self.title = title 67 | self.color = color 68 | self.action = action 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Toast/PresentToastAction.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension EnvironmentValues { 4 | /// Provides access to the toast presentation action within the environment. 5 | /// 6 | /// This value is only available after calling `installToast()` on a parent view in the hierarchy. 7 | public internal(set) var presentToast: PresentToastAction { 8 | get { self[PresentToastKey.self] } 9 | set { self[PresentToastKey.self] = newValue } 10 | } 11 | } 12 | 13 | /// Represents an action for presenting toast messages in SwiftUI views. 14 | @MainActor 15 | public struct PresentToastAction { 16 | internal weak var manager: ToastManager? 17 | 18 | /// Presents a toast with the specified configuration. 19 | /// 20 | /// - Parameter toast: The toast configuration to display. 21 | @MainActor 22 | public func callAsFunction(_ toast: ToastValue) { 23 | #if DEBUG 24 | if manager == nil { 25 | print( 26 | "View.installToast must be called on a parent view to use EnvironmentValues.presentToast." 27 | ) 28 | } 29 | #endif 30 | manager?.append(toast) 31 | } 32 | 33 | /// Presents a loading toast that automatically updates based on the result of an asynchronous task. 34 | /// 35 | /// - Parameters: 36 | /// - message: The loading message to display while the task is in progress. 37 | /// - task: The asynchronous task to execute. 38 | /// - onSuccess: A closure that returns a toast to display when the task succeeds. 39 | /// - onFailure: A closure that returns a toast to display when the task fails. 40 | /// - Returns: The result of the asynchronous task. 41 | /// - Throws: Any error thrown by the asynchronous task. 42 | @MainActor 43 | @discardableResult 44 | public func callAsFunction( 45 | message: String, 46 | task: sending () async throws -> sending V, 47 | onSuccess: (V) -> ToastValue, 48 | onFailure: (any Error) -> ToastValue 49 | ) async throws -> sending V { 50 | if let manager { 51 | return try await manager.append( 52 | message: message, 53 | task: task, 54 | onSuccess: onSuccess, 55 | onFailure: onFailure 56 | ) 57 | } else { 58 | print( 59 | "View.installToast must be called on a parent view to use EnvironmentValues.presentToast." 60 | ) 61 | return try await task() 62 | } 63 | } 64 | } 65 | 66 | private enum PresentToastKey: EnvironmentKey { 67 | static let defaultValue: PresentToastAction = .init() 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/ToastManager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | internal final class ToastManager: ObservableObject { 5 | 6 | @Published internal var position: ToastPosition = .top 7 | @Published internal private(set) var models: [ToastModel] = [] 8 | @Published internal private(set) var isAppeared = false 9 | @Published internal var safeAreaInsets: EdgeInsets = .init() 10 | private var dismissOverlayTask: Task? 11 | 12 | internal var isPresented: Bool { 13 | !models.isEmpty || isAppeared 14 | } 15 | 16 | nonisolated init() {} 17 | 18 | internal func onAppear() { 19 | isAppeared = true 20 | } 21 | 22 | @discardableResult 23 | internal func append(_ toast: ToastValue) -> ToastModel { 24 | dismissOverlayTask?.cancel() 25 | dismissOverlayTask = nil 26 | let model = ToastModel(value: toast) 27 | models.append(model) 28 | announceToAccessibility(toast.message) 29 | return model 30 | } 31 | 32 | internal func remove(_ model: ToastModel) { 33 | if let index = self.models.firstIndex(where: { $0 === model }) { 34 | self.models.remove(at: index) 35 | } 36 | if models.isEmpty { 37 | dismissOverlayTask = Task { 38 | try await Task.sleep(seconds: removalAnimationDuration) 39 | isAppeared = false 40 | } 41 | } 42 | } 43 | 44 | internal func startRemovalTask(for model: ToastModel) async { 45 | if let duration = model.value.duration { 46 | do { 47 | try await Task.sleep(seconds: duration) 48 | remove(model) 49 | } catch {} 50 | } 51 | } 52 | 53 | @discardableResult 54 | internal func append( 55 | message: String, 56 | task: sending () async throws -> sending V, 57 | onSuccess: (V) -> ToastValue, 58 | onFailure: (any Error) -> ToastValue 59 | ) async throws -> sending V { 60 | let model = append(ToastValue(icon: LoadingView(), message: message, duration: nil)) 61 | do { 62 | let value = try await task() 63 | let successToast = onSuccess(value) 64 | withAnimation(.spring(duration: 0.3)) { 65 | model.value = successToast 66 | } 67 | announceToAccessibility(successToast.message) 68 | return value 69 | } catch { 70 | let failureToast = onFailure(error) 71 | withAnimation(.spring(duration: 0.3)) { 72 | model.value = failureToast 73 | } 74 | announceToAccessibility(failureToast.message) 75 | throw error 76 | } 77 | } 78 | } 79 | 80 | internal let removalAnimationDuration: Double = 0.3 81 | -------------------------------------------------------------------------------- /ToastsExampleApp.swiftpm/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @testable import Toasts 4 | 5 | struct ContentView: View { 6 | @Environment(\.presentToast) var presentToast 7 | @State private var message = "" 8 | @State private var showTab = true 9 | var body: some View { 10 | TabView { 11 | Tab("Tab 1", systemImage: "square.and.arrow.up") { 12 | tab1 13 | } 14 | Tab("Tab 2", systemImage: "square.and.arrow.down") { 15 | Text("This is tab 2.") 16 | } 17 | } 18 | } 19 | 20 | private var tab1: some View { 21 | ScrollView { 22 | VStack(alignment: .leading) { 23 | TextField("Enter your message", text: $message) 24 | Toggle("Show Tab", isOn: $showTab) 25 | 26 | Button("Show Toast") { 27 | let toast = ToastValue( 28 | icon: Image(systemName: "bell"), 29 | message: "typed message: \(message)" 30 | ) 31 | presentToast(toast) 32 | } 33 | 34 | Button("Show Loading Toast") { 35 | Task { 36 | try await presentToast( 37 | message: "Loading...", 38 | task: { 39 | await loadSucceess() 40 | }, 41 | onSuccess: { result in 42 | ToastValue(icon: Image(systemName: "checkmark.circle"), message: result) 43 | }, 44 | onFailure: { error in 45 | ToastValue( 46 | icon: Image(systemName: "xmark.circle"), message: error.localizedDescription) 47 | }) 48 | } 49 | } 50 | 51 | Button("Show Loading Toast with Failure") { 52 | Task { 53 | try await presentToast( 54 | message: "Loading...", 55 | task: { 56 | try await loadFailure() 57 | }, 58 | onSuccess: { result in 59 | ToastValue(icon: Image(systemName: "checkmark.circle"), message: result) 60 | }, 61 | onFailure: { error in 62 | ToastValue( 63 | icon: Image(systemName: "xmark.circle"), message: error.localizedDescription) 64 | }) 65 | } 66 | } 67 | } 68 | } 69 | .addToastSafeAreaObserver() 70 | .toolbarVisibility(showTab ? .visible : .hidden, for: .tabBar) 71 | } 72 | 73 | private func loadSucceess() async -> String { 74 | try? await Task.sleep(seconds: 1) 75 | return "Success" 76 | } 77 | 78 | private func loadFailure() async throws -> String { 79 | try await Task.sleep(seconds: 1) 80 | throw NSError(domain: "Error", code: 1) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Toast/InstallToastModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import WindowOverlay 3 | 4 | extension View { 5 | /// Installs the toast presentation system on this view. 6 | /// 7 | /// This modifier should be applied to a parent view in the hierarchy to enable toast 8 | /// notifications in all child views. Child views can then present toasts using the 9 | /// `presentToast` environment value. 10 | /// 11 | /// - Parameter position: The vertical position where toasts will appear. Default is `.bottom`. 12 | /// - Returns: A view with toast presentation capability. 13 | public func installToast(position: ToastPosition = .bottom) -> some View { 14 | self.modifier(InstallToastModifier(position: position)) 15 | } 16 | } 17 | 18 | private struct InstallToastModifier: ViewModifier { 19 | var position: ToastPosition 20 | @State private var manager = ToastManager() 21 | func body(content: Content) -> some View { 22 | content 23 | .environment( 24 | \.presentToast, 25 | PresentToastAction(manager: manager) 26 | ) 27 | ._background { 28 | InstallToastView(manager: manager) 29 | } 30 | ._onChange(of: position, initial: true) { 31 | manager.position = $1 32 | } 33 | .addToastSafeAreaObserver() 34 | .onPreferenceChange(SafeAreaInsetsPreferenceKey.self) { 35 | manager.safeAreaInsets = $0 36 | } 37 | } 38 | } 39 | 40 | private struct InstallToastView: View { 41 | @ObservedObject var manager: ToastManager 42 | var body: some View { 43 | Color.clear 44 | .windowOverlay(isPresented: manager.isPresented, disableSafeArea: true) { 45 | ToastRootView(manager: manager) 46 | } 47 | } 48 | } 49 | 50 | extension View { 51 | /// Adds a notifier for safe area insets that the toast system can use for proper positioning. 52 | /// 53 | /// This is an internal helper method used by the toast system. 54 | public func addToastSafeAreaObserver() -> some View { 55 | self._background { 56 | GeometryReader { geometry in 57 | Color.clear 58 | .preference(key: SafeAreaInsetsPreferenceKey.self, value: geometry.safeAreaInsets) 59 | } 60 | } 61 | } 62 | } 63 | 64 | private enum SafeAreaInsetsPreferenceKey: PreferenceKey { 65 | static let defaultValue: EdgeInsets = .init() 66 | static func reduce(value: inout EdgeInsets, nextValue: () -> EdgeInsets) { 67 | let next = nextValue() 68 | value = EdgeInsets( 69 | top: max(value.top, next.top), 70 | leading: max(value.leading, next.leading), 71 | bottom: max(value.bottom, next.bottom), 72 | trailing: max(value.trailing, next.trailing)) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/ToastManagerTests/ToastManagerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import Toasts 4 | 5 | @MainActor 6 | final class ToastManagerTests: XCTestCase { 7 | func testAppendToast() { 8 | let manager = ToastManager() 9 | let toast = ToastValue(message: "Test Message") 10 | 11 | let model = manager.append(toast) 12 | 13 | XCTAssertEqual(manager.models.count, 1) 14 | XCTAssertTrue(manager.models.contains(where: { $0 === model })) 15 | XCTAssertEqual(model.message, "Test Message") 16 | } 17 | 18 | func testRemoveToast() { 19 | let manager = ToastManager() 20 | let toast = ToastValue(message: "Test Message") 21 | let model = manager.append(toast) 22 | 23 | manager.remove(model) 24 | 25 | XCTAssertTrue(manager.models.isEmpty) 26 | } 27 | 28 | func testIsPresented() { 29 | let manager = ToastManager() 30 | 31 | XCTAssertFalse(manager.isPresented) 32 | 33 | let toast = ToastValue(message: "Test Message") 34 | manager.append(toast) 35 | 36 | XCTAssertTrue(manager.isPresented) 37 | } 38 | 39 | func testOnAppear() { 40 | let manager = ToastManager() 41 | 42 | XCTAssertFalse(manager.isAppeared) 43 | 44 | manager.onAppear() 45 | 46 | XCTAssertTrue(manager.isAppeared) 47 | } 48 | 49 | func testStartRemovalTask() async { 50 | let manager = ToastManager() 51 | let toast = ToastValue(message: "Test Message", duration: 0.1) 52 | let model = manager.append(toast) 53 | 54 | await manager.startRemovalTask(for: model) 55 | 56 | XCTAssertTrue(manager.models.isEmpty) 57 | } 58 | 59 | func testAppendWithTask() async throws { 60 | let manager = ToastManager() 61 | 62 | let result = try await manager.append( 63 | message: "Loading...", 64 | task: { 65 | try await Task.sleep(seconds: 0.1) 66 | return "Success" 67 | }, 68 | onSuccess: { result in 69 | ToastValue(icon: Image(systemName: "checkmark.circle"), message: result) 70 | }, 71 | onFailure: { error in 72 | ToastValue(icon: Image(systemName: "xmark.circle"), message: error.localizedDescription) 73 | } 74 | ) 75 | 76 | XCTAssertEqual(result, "Success") 77 | XCTAssertEqual(manager.models.count, 1) 78 | XCTAssertEqual(manager.models.first?.message, "Success") 79 | } 80 | 81 | func testAppendWithErrorTask() async throws { 82 | let manager = ToastManager() 83 | 84 | do { 85 | try await manager.append( 86 | message: "Loading...", 87 | task: { 88 | try await Task.sleep(seconds: 0.1) 89 | throw NSError(domain: "", code: 0) 90 | }, 91 | onSuccess: { result in 92 | ToastValue(icon: Image(systemName: "checkmark.circle"), message: result) 93 | }, 94 | onFailure: { error in 95 | ToastValue(icon: Image(systemName: "xmark.circle"), message: "Error") 96 | } 97 | ) 98 | } catch {} 99 | XCTAssertEqual(manager.models.count, 1) 100 | XCTAssertEqual(manager.models.first?.message, "Error") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toasts 2 | 3 | A toast notification library for SwiftUI. 4 | 5 | ![Simulator Screen Recording - iPhone 15 Pro - 2024-09-16 at 11 53 37](https://github.com/user-attachments/assets/7b11b2f1-ed6e-4955-a674-c3bfd49ab8ad) 6 | 7 | ![Simulator Screen Recording - iPhone 16 Pro - 2024-09-18 at 10 53 57](https://github.com/user-attachments/assets/6c5f4906-aab6-4ef6-b9bb-844d7110586b) 8 | 9 | SCR-20240916-kqog 10 | 11 | ## Features 12 | 13 | - Easy-to-use toast notifications 14 | - Support for custom icons, messages, and buttons 15 | - Seamless integration with SwiftUI 16 | - Dark mode support 17 | - Slide gesture to dismiss 18 | - Loading state interface with async/await 19 | - Full VoiceOver compatibility for inclusive user experience 20 | 21 | ## Usage 22 | 23 | 1. Install toast in your root view: 24 | 25 | ```swift 26 | import SwiftUI 27 | import Toasts 28 | 29 | @main 30 | struct MyApp: App { 31 | var body: some Scene { 32 | WindowGroup { 33 | ContentView() 34 | .installToast(position: .bottom) 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | 2. Present a toast: 41 | 42 | ```swift 43 | @Environment(\.presentToast) var presentToast 44 | 45 | Button("Show Toast") { 46 | let toast = ToastValue( 47 | icon: Image(systemName: "bell"), 48 | message: "You have a new notification." 49 | ) 50 | presentToast(toast) 51 | } 52 | ``` 53 | 54 | ## Advanced Usage 55 | 56 | ```swift 57 | presentToast( 58 | message: "Loading...", 59 | task: { 60 | // Handle loading task 61 | return "Success" 62 | }, 63 | onSuccess: { result in 64 | ToastValue(icon: Image(systemName: "checkmark.circle"), message: result) 65 | }, 66 | onFailure: { error in 67 | ToastValue(icon: Image(systemName: "xmark.circle"), message: error.localizedDescription) 68 | } 69 | ) 70 | ``` 71 | 72 | ## Customization 73 | 74 | image 75 | 76 | - **Remove icon** 77 | 78 | ```swift 79 | let toast = ToastValue( 80 | message: "Message only toast." 81 | ) 82 | ``` 83 | 84 | - **Add button** 85 | 86 | ```swift 87 | let toast = ToastValue( 88 | message: "Toast with action required.", 89 | button: ToastButton(title: "Confirm", color: .green, action: { 90 | // Handle button action 91 | }) 92 | ) 93 | ``` 94 | 95 | ## Custom SafeArea Handling 96 | 97 | If you need to manually control the safe area insets for toasts (e.g., in a custom view hierarchy or when using multiple tabs), you can use the `addToastSafeAreaObserver` modifier: 98 | 99 | ```swift 100 | struct ContentView: View { 101 | var body: some View { 102 | TabView { 103 | Tab1View() 104 | Tab2View() 105 | } 106 | } 107 | } 108 | 109 | struct Tab1View: View { 110 | var body: some View { 111 | ScrollView { 112 | // Your content here 113 | } 114 | .addToastSafeAreaObserver() 115 | } 116 | } 117 | ``` 118 | 119 | This modifier helps the toast system correctly detect and respond to safe area changes, which is particularly useful in complex view hierarchies or when using TabView. 120 | 121 | ## Requirements 122 | 123 | - iOS 14.0+ 124 | - Swift 6.1+ 125 | - Xcode 16.4+ 126 | -------------------------------------------------------------------------------- /Sources/Toast/Internals/ToastView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct ToastView: View { 4 | @ObservedObject var model: ToastModel 5 | @Environment(\.colorScheme) private var colorScheme 6 | 7 | private var isDark: Bool { colorScheme == .dark } 8 | 9 | var body: some View { 10 | main 11 | ._background { 12 | Capsule().fill(Color.toastBackground) 13 | } 14 | .frame(height: 48) 15 | .compositingGroup() 16 | .shadow(color: .primary.opacity(isDark ? 0.0 : 0.1), radius: 16, y: 8.0) 17 | } 18 | 19 | private var main: some View { 20 | HStack(spacing: 10) { 21 | if let icon = model.icon { 22 | icon 23 | .frame(width: 19, height: 19) 24 | .padding(.leading, 15) 25 | } else { 26 | Color.clear 27 | .frame(width: 14) 28 | } 29 | Text(model.message) 30 | .lineLimit(1) 31 | .truncationMode(.tail) 32 | .id(model.message) 33 | .transition(.asymmetric( 34 | insertion: .opacity 35 | .animation(.spring(duration: 0.3).delay(0.3)), 36 | removal: .opacity 37 | .animation(.spring(duration: 0.3)) 38 | )) 39 | if let button = model.button { 40 | buttonView(button) 41 | .padding([.top, .bottom, .trailing], 10) 42 | } else { 43 | Color.clear 44 | .frame(width: 14) 45 | } 46 | } 47 | .font(.system(size: 16, weight: .medium)) 48 | } 49 | 50 | private func buttonView(_ button: ToastButton) -> some View { 51 | Button { 52 | button.action() 53 | } label: { 54 | ZStack { 55 | Capsule() 56 | .fill(button.color.opacity(isDark ? 0.15 : 0.07)) 57 | Text(button.title) 58 | ._foregroundColor(button.color) 59 | .padding(.horizontal, 9) 60 | } 61 | .frame(minWidth: 64) 62 | .fixedSize(horizontal: true, vertical: false) 63 | } 64 | .buttonStyle(.plain) 65 | } 66 | } 67 | 68 | @available(iOS 17.0, *) 69 | #Preview { 70 | let group = VStack { 71 | ToastView( 72 | model: .init( 73 | value: 74 | .init( 75 | icon: Image(systemName: "info.circle"), 76 | message: "This is a toast message", 77 | button: .init(title: "Action", color: .red, action: {}) 78 | ) 79 | ) 80 | ) 81 | ToastView( 82 | model: .init( 83 | value: 84 | .init( 85 | icon: Image(systemName: "info.circle"), 86 | message: "This is a toast message", 87 | button: .init(title: "Action", action: {}) 88 | ) 89 | ) 90 | ) 91 | ToastView( 92 | model: .init( 93 | value: 94 | .init( 95 | icon: Image(systemName: "info.circle"), 96 | message: "This is a toast message", 97 | button: nil 98 | ) 99 | ) 100 | ) 101 | ToastView( 102 | model: .init( 103 | value: 104 | .init( 105 | icon: nil, 106 | message: "This is a toast message", 107 | button: nil 108 | ) 109 | ) 110 | ) 111 | ToastView( 112 | model: .init( 113 | value: 114 | .init( 115 | icon: nil, 116 | message: "Copied", 117 | button: nil 118 | ) 119 | ) 120 | ) 121 | } 122 | return VStack { 123 | group 124 | group 125 | .padding(20) 126 | .background { 127 | Color.black 128 | } 129 | .environment(\.colorScheme, .dark) 130 | } 131 | } 132 | --------------------------------------------------------------------------------