├── .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 | 
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 |
--------------------------------------------------------------------------------