├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE.txt ├── Package.swift ├── README.md └── Sources └── Toast ├── Extensions ├── Image+SFSymbol.swift ├── VerticalEdge+Alignment.swift └── View+Toast.swift ├── Models └── Toast.swift ├── Modifiers ├── ToastModifier.swift └── ToastTrailingButtonStyle.swift ├── Protocols └── ToastStyle.swift └── Views └── ToastMessageView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | #.DS_Store 65 | .DS_Store 66 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sedlacek Solutions 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. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Toast", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v17), .macOS(.v14)], 10 | products: [ 11 | .library( 12 | name: "Toast", 13 | targets: ["Toast"]), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "Toast"), 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toast 2 | 3 | ## Description 4 | `Toast` is a lightweight SwiftUI library that provides a simple way to display toast messages. 5 | 6 | ## Requirements 7 | 8 | | Platform | Minimum Version | 9 | |----------|-----------------| 10 | | iOS | 17.0 | 11 | | macOS | 14.0 | 12 | 13 | ## Get Started 14 | 15 | 1. Toast ViewModifier 16 | ```swift 17 | import Toast 18 | import SwiftUI 19 | 20 | @MainActor 21 | struct ExampleScreen { 22 | @State var isLoading: Bool = false 23 | @State var toastToPresent: Toast? = nil 24 | 25 | @Sendable func onTask() async { 26 | isLoading = true 27 | defer { isLoading = false } 28 | 29 | do { 30 | try await Task.sleep(for: .seconds(1)) 31 | toastToPresent = .success(message: "Successfully did a thing!") 32 | } catch { 33 | toastToPresent = .error(message: "Failure to do a thing!") 34 | } 35 | } 36 | } 37 | 38 | extension ExampleScreen: View { 39 | var body: some View { 40 | VStack { 41 | Spacer() 42 | } 43 | .task(onTask) 44 | .toast($toastToPresent) 45 | } 46 | } 47 | ``` 48 | 49 | 2. Convenience Initializers 50 | ```swift 51 | /// Extension to the Toast struct to provide convenience initializers for different types of toasts. 52 | extension Toast { 53 | /// Creates a debug toast with a purple color and a debug icon. 54 | public static func debug(message: String) -> Toast {...} 55 | 56 | /// Creates an error toast with a red color and an error icon. 57 | public static func error(message: String) -> Toast {...} 58 | 59 | /// Creates an info toast with a blue color and an info icon. 60 | public static func info(message: String) -> Toast {...} 61 | 62 | /// Creates a notice toast with an orange color and a notice icon. 63 | public static func notice(message: String) -> Toast {...} 64 | 65 | /// Creates a success toast with a green color and a success icon. 66 | public static func success(message: String) -> Toast {...} 67 | 68 | /// Creates a warning toast with a yellow color and a warning icon. 69 | public static func warning(message: String) -> Toast {...} 70 | } 71 | ``` 72 | 73 | 3. Additional Options for Toast ViewModifier 74 | ```swift 75 | /// Shows a toast with a provided configuration. 76 | /// - Parameters: 77 | /// - toast: A binding to the toast to display. 78 | /// - edge: The edge of the screen where the toast appears. 79 | /// - autoDismissable: Whether the toast should automatically dismiss. 80 | /// - onDismiss: A closure to call when the toast is dismissed. 81 | /// - trailingView: A closure that returns a trailing view to be displayed in the toast. 82 | func toast( 83 | _ toast: Binding, 84 | edge: VerticalEdge = .top, 85 | autoDismissable: Bool = false, 86 | onDismiss: @escaping () -> Void = {}, 87 | @ViewBuilder trailingView: @escaping () -> TrailingView = { EmptyView() } 88 | ) -> some View {...} 89 | ``` 90 | 91 | 4. Adding a Trailing Views to Toasts 92 | ```swift 93 | /// Add interactive elements such as buttons, icons, or loading indicators to the toast message. 94 | /// Example usage: 95 | @MainActor 96 | struct ExampleView { 97 | @State private var toastToPresent: Toast? = nil 98 | 99 | private func showAction() { 100 | toastToPresent = .notice(message: "A software update is available.") 101 | } 102 | 103 | private func updateAction() { 104 | print("Update Pressed") 105 | } 106 | } 107 | 108 | extension ExampleView: View { 109 | var body: some View { 110 | Button("Show Update Toast", action: showAction) 111 | .frame(maxWidth: .infinity, maxHeight: .infinity) 112 | .padding(40) 113 | .toast($toastToPresent, trailingView: updateButton) 114 | } 115 | 116 | @ViewBuilder 117 | private func updateButton() -> some View { 118 | if let toastToPresent { 119 | Button("Update", action: updateAction) 120 | .buttonStyle( 121 | .toastTrailing(tintColor: toastToPresent.color) 122 | ) 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | 129 | ## Custom Toast Styles 130 | 131 | You can create and apply your own `ToastStyle` across the app or per instance. 132 | 133 | ### Define a Custom Style 134 | 135 | ```swift 136 | /// Example of a reusable toast style 137 | struct ExampleToastStyle: ToastStyle { 138 | func makeBody(configuration: ToastStyleConfiguration) -> some View { 139 | HStack(spacing: 10) { 140 | configuration.toast.icon 141 | .font(.headline) 142 | .fontWeight(.semibold) 143 | .foregroundStyle(configuration.toast.color) 144 | .padding(8) 145 | .background(RoundedRectangle(cornerRadius: 12).fill(configuration.toast.color.opacity(0.3))) 146 | 147 | Text(configuration.toast.message) 148 | .font(.headline) 149 | .fontWeight(.semibold) 150 | .foregroundStyle(.primary) 151 | 152 | Spacer(minLength: .zero) 153 | 154 | configuration.trailingView 155 | } 156 | .frame(maxWidth: .infinity, alignment: .leading) 157 | .padding(8) 158 | .background(.ultraThinMaterial) 159 | .cornerRadius(12) 160 | .padding() 161 | } 162 | } 163 | ``` 164 | 165 | ### Apply a Global Style 166 | 167 | To apply the same toast style across your entire view hierarchy, attach it like this: 168 | 169 | ```swift 170 | ContentView() 171 | .toastStyle(ExampleToastStyle()) 172 | ``` 173 | 174 | ### Override Style on a Specific Toast 175 | 176 | If you want to override the style for a particular toast, pass it directly into the toast modifier: 177 | 178 | ```swift 179 | .toast( 180 | $toast, 181 | style: ExampleToastStyle(), 182 | edge: .top, 183 | autoDismissable: true, 184 | onDismiss: { print("Dismissed") }, 185 | trailingView: { 186 | Button("Action") { /* Do something */ } 187 | } 188 | ) 189 | ``` 190 | 191 | This gives you flexibility to mix and match styles depending on context. 192 | 193 | --- 194 | 195 | ## Features 196 | - **Multiple Toast Types**: `success`, `error`, `info`, `warning`, `notice` 197 | - **Supports Trailing Views**: Buttons, Icons, Loaders 198 | - **Auto-Dismiss & Manual Dismiss**: Configurable behavior 199 | - **Flexible Customization**: Accepts any SwiftUI view as a trailing element 200 | 201 | ## Example Use Cases 202 | 203 | | Feature | Example | 204 | |--------------------|------------------------------------------------| 205 | | Simple Toast | `.toast($toast)` | 206 | | Actionable Toast | `.toast($toast) { Button("OK") { ... } }` | 207 | | Loading Indicator | `.toast($toast) { ProgressView() }` | 208 | | Auto-dismiss Toast | `.toast($toast, autoDismissable: true)` | 209 | 210 | ## Previews 211 | 212 | https://github.com/user-attachments/assets/a22d7e4e-e3dd-4733-8070-235c631e8292 213 | 214 | ![Example of Custom ToastStyle](https://github.com/user-attachments/assets/b7a1e2c6-468f-4243-acca-8f63f96f41da) 215 | -------------------------------------------------------------------------------- /Sources/Toast/Extensions/Image+SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+SFSymbol.swift 3 | // 4 | // Created by James Sedlacek on 12/17/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension Image { 10 | enum SFSymbol: String { 11 | case debug = "ladybug" 12 | case error = "exclamationmark.octagon" 13 | case info = "info.circle" 14 | case notice = "bell" 15 | case success = "checkmark.circle" 16 | case warning = "exclamationmark.triangle" 17 | } 18 | 19 | init(_ symbol: SFSymbol) { 20 | self.init(systemName: symbol.rawValue) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Toast/Extensions/VerticalEdge+Alignment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalEdge+Alignment.swift 3 | // 4 | // Created by James Sedlacek on 12/18/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension VerticalEdge { 10 | var alignment: Alignment { 11 | switch self { 12 | case .top: .top 13 | case .bottom: .bottom 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Toast/Extensions/View+Toast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Toast.swift 3 | // 4 | // Created by James Sedlacek on 12/18/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | /// Extension to add a toast to any View. 10 | public extension View { 11 | 12 | /// Shows a toast with a provided configuration. 13 | /// - Parameters: 14 | /// - toast: A binding to the toast to display. 15 | /// - edge: The edge of the screen where the toast appears. 16 | /// - autoDismissable: Whether the toast should automatically dismiss. 17 | /// - onDismiss: A closure to call when the toast is dismissed. 18 | /// - trailingView: A closure that returns a trailing view to be displayed in the toast. 19 | func toast( 20 | _ toast: Binding, 21 | edge: VerticalEdge = .top, 22 | autoDismissable: Bool = false, 23 | onDismiss: @escaping () -> Void = {}, 24 | @ViewBuilder trailingView: @escaping () -> TrailingView = { EmptyView() } 25 | ) -> some View { 26 | modifier( 27 | ToastModifier( 28 | toast: toast, 29 | edge: edge, 30 | isAutoDismissed: autoDismissable, 31 | onDismiss: onDismiss, 32 | trailingView: trailingView() 33 | ) 34 | ) 35 | } 36 | } 37 | 38 | public extension View { 39 | /// Presents a `Toast` using the built‑in default style or any `ToastStyle` 40 | /// provided with `.toastStyle(_:)` higher in the view hierarchy. 41 | /// 42 | /// - Parameters: 43 | /// - toast: A binding to the `Toast` to display. 44 | /// - edge: The screen edge where the toast appears (`.top` or `.bottom`). 45 | /// - isAutoDismissed: Pass `true` to let the toast dismiss itself after a 46 | /// delay, or `false` to keep it onscreen until the user swipes it away. 47 | /// - onDismiss: A closure that’s called after the toast is dismissed 48 | /// (either automatically or by the user). 49 | /// - trailingView: An optional trailing view—such as a button or progress 50 | /// indicator—displayed at the right edge of the toast. 51 | func toast( 52 | _ toast: Binding, 53 | edge: VerticalEdge = .top, 54 | isAutoDismissed: Bool = true, 55 | onDismiss: @escaping () -> Void = {}, 56 | @ViewBuilder trailingView: () -> T = { EmptyView() } 57 | ) -> some View { 58 | modifier( 59 | ToastModifier( 60 | toast: toast, 61 | edge: edge, 62 | isAutoDismissed: isAutoDismissed, 63 | onDismiss: onDismiss, 64 | trailingView: trailingView() 65 | ) 66 | ) 67 | } 68 | 69 | /// Presents a `Toast` with a custom `ToastStyle`, overriding both the 70 | /// default look and any style supplied via `.toastStyle(_:)`. 71 | /// 72 | /// - Parameters: 73 | /// - toast: A binding to the `Toast` to display. 74 | /// - style: The `ToastStyle` to apply to *this* toast only. 75 | /// - edge: The screen edge where the toast appears (`.top` or `.bottom`). 76 | /// - isAutoDismissed: Pass `true` to let the toast dismiss itself after a 77 | /// delay, or `false` to keep it onscreen until the user swipes it away. 78 | /// - onDismiss: A closure that’s called after the toast is dismissed 79 | /// (either automatically or by the user). 80 | /// - trailingView: An optional trailing view—such as a button or progress 81 | /// indicator—displayed at the right edge of the toast. 82 | func toast( 83 | _ toast: Binding, 84 | style: S, 85 | edge: VerticalEdge = .top, 86 | isAutoDismissed: Bool = true, 87 | onDismiss: @escaping () -> Void = {}, 88 | @ViewBuilder trailingView: () -> T = { EmptyView() } 89 | ) -> some View { 90 | modifier( 91 | ToastModifier( 92 | toast: toast, 93 | edge: edge, 94 | isAutoDismissed: isAutoDismissed, 95 | onDismiss: onDismiss, 96 | trailingView: trailingView(), 97 | style: AnyToastStyle(style) 98 | ) 99 | ) 100 | } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /Sources/Toast/Models/Toast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toast.swift 3 | // 4 | // Created by James Sedlacek on 12/17/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public struct Toast { 10 | public let icon: Image 11 | public let color: Color 12 | public let message: String 13 | 14 | public init( 15 | icon: Image, 16 | color: Color, 17 | message: String 18 | ) { 19 | self.icon = icon 20 | self.color = color 21 | self.message = message 22 | } 23 | } 24 | 25 | extension Toast: Hashable { 26 | public func hash(into hasher: inout Hasher) { 27 | hasher.combine(color) 28 | hasher.combine(message) 29 | } 30 | } 31 | 32 | /// Extension to the Toast struct to provide convenience initializers for different types of toasts. 33 | extension Toast { 34 | /// Creates a debug toast with a purple color and a debug icon. 35 | public static func debug(message: String) -> Toast { 36 | .init(icon: Image(.debug), color: .purple, message: message) 37 | } 38 | 39 | /// Creates an error toast with a red color and an error icon. 40 | public static func error(message: String) -> Toast { 41 | .init(icon: Image(.error), color: .red, message: message) 42 | } 43 | 44 | /// Creates an info toast with a blue color and an info icon. 45 | public static func info(message: String) -> Toast { 46 | .init(icon: Image(.info), color: .blue, message: message) 47 | } 48 | 49 | /// Creates a notice toast with an orange color and a notice icon. 50 | public static func notice(message: String) -> Toast { 51 | .init(icon: Image(.notice), color: .orange, message: message) 52 | } 53 | 54 | /// Creates a success toast with a green color and a success icon. 55 | public static func success(message: String) -> Toast { 56 | .init(icon: Image(.success), color: .green, message: message) 57 | } 58 | 59 | /// Creates a warning toast with a yellow color and a warning icon. 60 | public static func warning(message: String) -> Toast { 61 | .init(icon: Image(.warning), color: .yellow, message: message) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Toast/Modifiers/ToastModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastModifier.swift 3 | // 4 | // Created by James Sedlacek on 1/5/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | //MARK: - ToastStyle Environment Support 10 | 11 | /// Environment key that stores the style to be user by 'ToastMessageView' 12 | private struct ToastStyleEnvironmentKey: EnvironmentKey { 13 | /// 'nil' means fallback to the built-in default style 14 | static let defaultValue: AnyToastStyle? = nil 15 | } 16 | 17 | extension EnvironmentValues { 18 | /// Current 'ToastStyle' for the view hierarchy, if one was provided with 19 | /// '.toastStyle(_: )''. 20 | var toastStyle: AnyToastStyle? { 21 | get { self[ToastStyleEnvironmentKey.self] } 22 | set { self[ToastStyleEnvironmentKey.self] = newValue } 23 | } 24 | } 25 | 26 | public extension View { 27 | /// Sets a custom 'ToastStyle' for the new hierarchy. Works just like .buttonStyle(_:), so you can call it high up in the view tree once: 28 | /// 29 | /// ContentView() 30 | /// .toastStyle(MyToastStyle()) 31 | /// 32 | /// Every 'Toast' presented below that call will automatically adopt the given style, unless an explicit style is supplied to the individual '.toast' invocation. 33 | 34 | func toastStyle(_ style: S) -> some View { 35 | environment(\.toastStyle, AnyToastStyle(style)) 36 | } 37 | } 38 | 39 | @MainActor 40 | struct ToastModifier: ViewModifier { 41 | private let edge: VerticalEdge 42 | private let offset: CGFloat 43 | private let isAutoDismissed: Bool 44 | private let onDismiss: () -> Void 45 | private let trailingView: TrailingView 46 | @Binding private var toast: Toast? 47 | @Environment(\.toastStyle) private var environmentStyle 48 | private let explicitStyle: AnyToastStyle? 49 | @State private var isPresented: Bool = false 50 | 51 | private var yOffset: CGFloat { 52 | isPresented ? .zero : offset 53 | } 54 | 55 | init( 56 | toast: Binding, 57 | edge: VerticalEdge, 58 | isAutoDismissed: Bool, 59 | onDismiss: @escaping () -> Void, 60 | trailingView: TrailingView, 61 | style: AnyToastStyle? = nil 62 | ) { 63 | self._toast = toast 64 | self.edge = edge 65 | self.isAutoDismissed = isAutoDismissed 66 | self.trailingView = trailingView 67 | self.onDismiss = onDismiss 68 | self.offset = edge == .top ? -200 : 200 69 | self.explicitStyle = style 70 | } 71 | 72 | private func onChangeDragGesture(_ value: DragGesture.Value) { 73 | dismissToastAnimation() 74 | } 75 | 76 | private func onChangeToast(_ oldToast: Toast?, _ newToast: Toast?) { 77 | if newToast != nil { 78 | presentToastAnimation() 79 | } 80 | } 81 | 82 | private func presentToastAnimation() { 83 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 84 | withAnimation(.spring()) { 85 | isPresented = true 86 | } 87 | } 88 | if isAutoDismissed { 89 | autoDismissToastAnimation() 90 | } 91 | } 92 | 93 | private func dismissToastAnimation() { 94 | withAnimation(.easeOut(duration: 0.8)) { 95 | isPresented = false 96 | } 97 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 98 | toast = nil 99 | onDismiss() 100 | } 101 | } 102 | 103 | private func autoDismissToastAnimation() { 104 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.6) { 105 | dismissToastAnimation() 106 | } 107 | } 108 | 109 | func body(content: Content) -> some View { 110 | content 111 | .onChange(of: toast, onChangeToast) 112 | .overlay( 113 | alignment: edge.alignment, 114 | content: toastView 115 | ) 116 | } 117 | 118 | @ViewBuilder 119 | private func toastView() -> some View { 120 | if let toast { 121 | let chosenStyle = explicitStyle ?? environmentStyle 122 | 123 | if let style = chosenStyle { 124 | ToastMessageView(toast, 125 | style: style, 126 | trailingView: {trailingView}) 127 | .offset(y: yOffset) 128 | .gesture(dragGesture) 129 | } else { 130 | ToastMessageView(toast, trailingView: { trailingView }) 131 | .offset(y: yOffset) 132 | .gesture(dragGesture) 133 | } 134 | } 135 | } 136 | 137 | private var dragGesture: some Gesture { 138 | DragGesture(minimumDistance: .zero) 139 | .onChanged(onChangeDragGesture) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Sources/Toast/Modifiers/ToastTrailingButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastTrailingButtonStyle.swift 3 | // 4 | // Created by Gaurav Bhambhani on 1/22/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public struct ToastTrailingButtonStyle: ButtonStyle { 10 | private let tintColor: Color 11 | 12 | public init(tintColor: Color) { 13 | self.tintColor = tintColor 14 | } 15 | 16 | public func makeBody(configuration: Configuration) -> some View { 17 | configuration.label 18 | .padding(.horizontal, 10) 19 | .padding(.vertical, 5) 20 | .fontWeight(.semibold) 21 | .foregroundStyle(tintColor) 22 | .background( 23 | tintColor.opacity(0.2), 24 | in: .rect(cornerRadius: 5) 25 | ) 26 | .opacity(configuration.isPressed ? 0.7 : 1.0) 27 | } 28 | } 29 | 30 | extension ButtonStyle where Self == ToastTrailingButtonStyle { 31 | @MainActor 32 | public static func toastTrailing(tintColor: Color) -> ToastTrailingButtonStyle { 33 | .init(tintColor: tintColor) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Toast/Protocols/ToastStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastStyle.swift 3 | // Toast 4 | // 5 | // Created by Josh Bourke on 3/5/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A type‑erased description of how a toast should be drawn. 11 | /// 12 | /// Conformers supply a `makeBody(configuration:)` method, much like SwiftUI’s 13 | /// `ButtonStyle`. Use this protocol to design reusable visual treatments for 14 | /// `ToastMessageView` instances. 15 | /// 16 | /// Create your own style by conforming: 17 | /// 18 | /// ```swift 19 | /// struct MyToastStyle: ToastStyle { 20 | /// func makeBody(configuration: ToastStyleConfiguration) -> some View { 21 | /// // build and return a styled view here 22 | /// } 23 | /// } 24 | /// ``` 25 | @MainActor public protocol ToastStyle { 26 | associatedtype Body: View 27 | func makeBody(configuration: ToastStyleConfiguration) -> Body 28 | } 29 | 30 | /// The information a `ToastStyle` needs in order to render a toast. 31 | /// 32 | /// You receive an instance of this struct inside 33 | /// `ToastStyle.makeBody(configuration:)`. It gives you access to: 34 | /// * `toast` – the `Toast` data (icon, message, colour, type, …) 35 | /// * `trailingView` – any custom trailing view supplied by the caller 36 | /// (e.g. a button or progress indicator) wrapped in `AnyView`. 37 | 38 | public struct ToastStyleConfiguration { 39 | public let toast: Toast 40 | public let trailingView: AnyView 41 | } 42 | 43 | /// A type‑erased wrapper that lets us store _any_ `ToastStyle` in places 44 | /// that require a concrete type (e.g. the SwiftUI environment). 45 | /// 46 | /// You generally don’t create `AnyToastStyle` directly; it’s used internally 47 | /// by the library. However, you can erase a style’s type when needed: 48 | /// 49 | /// ```swift 50 | /// let erased = AnyToastStyle(MyToastStyle()) 51 | /// ``` 52 | 53 | @MainActor public struct AnyToastStyle: ToastStyle { 54 | private let _makeBody: (ToastStyleConfiguration) -> AnyView 55 | 56 | public init(_ style: S) { 57 | self._makeBody = { AnyView(style.makeBody(configuration: $0)) } 58 | } 59 | 60 | public func makeBody(configuration: ToastStyleConfiguration) -> AnyView { 61 | _makeBody(configuration) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Toast/Views/ToastMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastMessageView.swift 3 | // 4 | // Created by James Sedlacek on 12/17/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct ToastMessageView: View { 10 | private let toast: Toast 11 | private let trailingView: TrailingView 12 | private let explicitStyle: AnyToastStyle? 13 | @Environment(\.toastStyle) private var environmentStyle 14 | 15 | //MARK: - Initialisers 16 | 17 | /// Default (users environment or built-in style 18 | init( 19 | _ toast: Toast, 20 | @ViewBuilder trailingView: () -> TrailingView = { EmptyView() } 21 | ) { 22 | self.toast = toast 23 | self.trailingView = trailingView() 24 | self.explicitStyle = nil 25 | } 26 | 27 | /// Allows overriding the style just for this toast 28 | init( 29 | _ toast: Toast, 30 | style: S, 31 | @ViewBuilder trailingView: () -> TrailingView = { EmptyView() } 32 | ) { 33 | self.toast = toast 34 | self.trailingView = trailingView() 35 | self.explicitStyle = AnyToastStyle(style) 36 | } 37 | 38 | // MARK: - Body 39 | 40 | 41 | var body: some View { 42 | let chosenStyle = explicitStyle ?? environmentStyle 43 | 44 | if let style = chosenStyle { 45 | /// Use custom style 46 | style.makeBody(configuration: configuration) 47 | } else { 48 | /// Use built-in default look 49 | defaultBody 50 | } 51 | 52 | } 53 | 54 | //MARK: - Helpers 55 | private var configuration: ToastStyleConfiguration { 56 | .init(toast: toast, trailingView: AnyView(trailingView)) 57 | } 58 | 59 | private var defaultBody: some View { 60 | HStack(spacing: 10) { 61 | toast.icon 62 | .font(.system(size: 24, weight: .medium)) 63 | .foregroundStyle(toast.color) 64 | 65 | Text(toast.message) 66 | .font(.system(size: 18, weight: .semibold)) 67 | .foregroundStyle(.primary) 68 | 69 | Spacer(minLength: .zero) 70 | 71 | trailingView 72 | } 73 | .frame(maxWidth: .infinity, alignment: .leading) 74 | .padding() 75 | .background(backgroundView) 76 | .padding() 77 | } 78 | 79 | private var backgroundView: some View { 80 | RoundedRectangle(cornerRadius: 10) 81 | .fill(.background.secondary) 82 | .fill(toast.color.opacity(0.08)) 83 | .stroke(toast.color, lineWidth: 2) 84 | } 85 | } 86 | 87 | // MARK: Example Usage 88 | /// An example of how to construct your very own Toast Style. 89 | struct ExampleToastStyle: ToastStyle { 90 | func makeBody(configuration: ToastStyleConfiguration) -> some View { 91 | HStack(spacing: 10) { 92 | configuration.toast.icon 93 | .font(.headline) 94 | .fontWeight(.semibold) 95 | .foregroundStyle(configuration.toast.color) 96 | .padding(8) 97 | .background(RoundedRectangle(cornerRadius: 12).fill(configuration.toast.color.opacity(0.3))) 98 | 99 | Text(configuration.toast.message) 100 | .font(.headline) 101 | .fontWeight(.semibold) 102 | .foregroundStyle(.primary) 103 | 104 | Spacer(minLength: .zero) 105 | 106 | configuration.trailingView 107 | } 108 | .frame(maxWidth: .infinity, alignment: .leading) 109 | .padding(8) 110 | .background(.ultraThinMaterial, in: .rect(cornerRadius: 12)) 111 | .padding() 112 | 113 | } 114 | } 115 | 116 | extension ToastMessageView where TrailingView == EmptyView { 117 | static var infoExample: some View { 118 | ToastMessageView(.info(message: "Something informational for the user.")) 119 | } 120 | 121 | static var successExample: some View { 122 | ToastMessageView(.success(message: "Successfully did the thing!")) 123 | } 124 | 125 | static var debugExample: some View { 126 | ToastMessageView(.debug(message: "Line 32 in `File.swift` executed.")) 127 | } 128 | } 129 | 130 | struct NetworkErrorExample: View { 131 | var body: some View { 132 | ToastMessageView(.error(message: "Network Error!")) { 133 | ProgressView() 134 | } 135 | } 136 | } 137 | 138 | struct SomethingWrongExample: View { 139 | var body: some View { 140 | ToastMessageView(.warning(message: "Something went wrong!")) { 141 | Button(action: { 142 | print("Go to logs") 143 | }) { 144 | Image(systemName: "doc.text.magnifyingglass") 145 | } 146 | .buttonStyle(.toastTrailing(tintColor: .yellow)) 147 | } 148 | } 149 | } 150 | 151 | @MainActor 152 | struct ExampleView { 153 | @State private var toastToPresent: Toast? = nil 154 | 155 | private func showAction() { 156 | toastToPresent = .notice(message: "A software update is available.") 157 | } 158 | 159 | private func updateAction() { 160 | print("Update Pressed") 161 | } 162 | } 163 | 164 | extension ExampleView: View { 165 | var body: some View { 166 | Button("Show Update Toast", action: showAction) 167 | .frame(maxWidth: .infinity, maxHeight: .infinity) 168 | .padding(40) 169 | .toast($toastToPresent, trailingView: updateButton) 170 | } 171 | 172 | @ViewBuilder 173 | private func updateButton() -> some View { 174 | if let toastToPresent { 175 | Button("Update", action: updateAction) 176 | .buttonStyle( 177 | .toastTrailing(tintColor: toastToPresent.color) 178 | ) 179 | } 180 | } 181 | } 182 | 183 | #Preview { 184 | VStack(spacing: 16) { 185 | ExampleView() 186 | ToastMessageView.infoExample 187 | ToastMessageView.successExample 188 | ToastMessageView.debugExample 189 | NetworkErrorExample() 190 | SomethingWrongExample() 191 | } 192 | .toastStyle(ExampleToastStyle()) 193 | .background(.background) 194 | } 195 | --------------------------------------------------------------------------------