Skip to content

Commit 0d0ed90

Browse files
committed
Add ability to display toasts
1 parent c2dc967 commit 0d0ed90

File tree

7 files changed

+326
-12
lines changed

7 files changed

+326
-12
lines changed

Package.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import PackageDescription
66
let package = Package(
77
name: "ViewStateController",
88
platforms: [
9-
.iOS(.v15),
10-
.macOS(.v12)
9+
.iOS(.v15)
1110
],
1211
products: [
1312
.library(

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,29 @@
22

33
ViewStateController is a framework for Swift and SwiftUI developers that provides a simple and flexible way to manage the state of views that load information from a backend. It allows you to handle different states based on a historical array of states, and provides properties and methods to help you access and modify the state. With ViewStateController, you can easily implement complex views that depend on asynchronous data loading, and create a better user experience by showing loading spinners or error messages.
44

5-
## ViewStateController Object
5+
# ViewStateController Object
66

7-
## WithViewState Modifier
7+
# WithViewState Modifier
88

99
## Examples with code samples
1010

11+
# Toast
1112

12-
## BuildTools
1313

14-
To run the formatter, just run:
1514

16-
`swiftformat . --config "Sources/ControllerExampleApp/.swiftformat" --swiftversion 5.7` from the root of the repository.
15+
16+
# Internal Project Tools
17+
18+
## ExampleApp
19+
20+
There is an Example App where most of the configurable options can be tweaked to test the different states/looks/behaviors of loading/error/toasts.
21+
22+
The project is in `Sources/ControllerExampleApp/ControllerExampleApp.xcodeproj`
23+
24+
## Formatter
25+
26+
To run the formatter, just run the following command from the root of the repository:
27+
28+
`swiftformat . --config "Sources/ControllerExampleApp/.swiftformat" --swiftversion 5.7`
1729

1830
You need to have `SwiftFormat` installed (`brew install swiftformat`).

Sources/ControllerExampleApp/ControllerExampleApp/Screens/Demo/DemoScreen.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct DemoScreen: View {
1313
@State private var initialLoadingType: LoadingModifierType = .overCurrentContent()
1414
@State private var loadingAfterInfoType: LoadingModifierType = .overCurrentContent()
1515
@State private var loadingAfterErrorType: LoadingModifierType = .overCurrentContent()
16+
@State private var displayToast: Bool = false
1617

1718
var body: some View {
1819
NavigationView {
@@ -54,6 +55,15 @@ struct DemoScreen: View {
5455
}
5556
buttons
5657
}
58+
.toast(
59+
isShowing: $displayToast,
60+
type: .default(
61+
options: .init(
62+
message: .init(text: "Hola"),
63+
secondaryMessage: .init(text: "Hey", font: .system(size: 10))
64+
)
65+
)
66+
)
5767
.padding()
5868
.navigationTitle("Demo")
5969
.navigationBarTitleDisplayMode(.inline)
@@ -76,12 +86,16 @@ struct DemoScreen: View {
7686
}
7787

7888
var buttons: some View {
79-
HStack {
89+
VStack {
90+
HStack {
91+
Button("Loading") { setLoading() }
92+
Button("Loaded") { setLoaded() }
93+
Button("Error") { setError() }
94+
Button("Toast") { withAnimation { displayToast = true } }
95+
}
8096
Button("Reset") { controller.reset() }
81-
Button("Loading") { setLoading() }
82-
Button("Loaded") { setLoaded() }
83-
Button("Error") { setError() }
84-
}.buttonStyle(.bordered)
97+
}
98+
.buttonStyle(.bordered)
8599
}
86100

87101
func loadedView(user: User) -> some View {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// DefaultToastOptions.swift
3+
//
4+
//
5+
// Created by Manu on 01/03/2023.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
/// Configuration object for the toasts.
12+
public struct DefaultToastOptions {
13+
public struct Message {
14+
let text: String
15+
let color: Color
16+
let font: Font
17+
let alignment: Alignment
18+
19+
public init(
20+
text: String,
21+
color: Color = .white,
22+
font: Font = .system(size: 16),
23+
alignment: Alignment = .leading
24+
) {
25+
self.text = text
26+
self.color = color
27+
self.font = font
28+
self.alignment = alignment
29+
}
30+
}
31+
32+
public struct TrailingButton {
33+
let imageSystemName: String
34+
let color: Color
35+
let size: CGSize
36+
37+
public init(
38+
imageSystemName: String = "xmark",
39+
color: Color = .white,
40+
size: CGSize = .init(width: 12, height: 12)
41+
) {
42+
self.imageSystemName = imageSystemName
43+
self.color = color
44+
self.size = size
45+
}
46+
}
47+
48+
public struct Background {
49+
let color: Color
50+
let cornerRadius: CGFloat
51+
52+
public init(
53+
color: Color = .accentColor,
54+
cornerRadius: CGFloat = 8
55+
) {
56+
self.color = color
57+
self.cornerRadius = cornerRadius
58+
}
59+
}
60+
61+
let message: Message
62+
let secondaryMessage: Message?
63+
let trailingButton: TrailingButton?
64+
let background: Background
65+
let internalPadding: UIEdgeInsets
66+
67+
public init(
68+
message: Message,
69+
secondaryMessage: Message? = nil,
70+
trailingButton: TrailingButton? = .init(),
71+
background: Background = .init(),
72+
internalPadding: UIEdgeInsets = .init(top: 16, left: 16, bottom: 16, right: 16)
73+
) {
74+
self.message = message
75+
self.secondaryMessage = secondaryMessage
76+
self.trailingButton = trailingButton
77+
self.background = background
78+
self.internalPadding = internalPadding
79+
}
80+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//
2+
// ToastModifier.swift
3+
//
4+
//
5+
// Created by Manu on 01/03/2023.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
public extension View {
12+
/// Adds the Toast modifier.
13+
func toast(
14+
isShowing: Binding<Bool>,
15+
type: ToastType,
16+
transitionOptions: ToastTransitionOptions = .init(),
17+
positionOptions: ToastPositionOptions = .init(),
18+
onTap: (() -> Void)? = nil
19+
) -> some View {
20+
modifier(
21+
ToastModifier(
22+
isShowing: isShowing,
23+
type: type,
24+
transitionOptions: transitionOptions,
25+
positionOptions: positionOptions,
26+
onTap: onTap
27+
)
28+
)
29+
}
30+
}
31+
32+
/// Display a toast message for some (configurable) seconds. Then disappears.
33+
public struct ToastModifier: ViewModifier {
34+
@Binding public var isShowing: Bool
35+
private let type: ToastType
36+
private let transitionOptions: ToastTransitionOptions
37+
private let positionOptions: ToastPositionOptions
38+
private let onTap: (() -> Void)?
39+
40+
public init(
41+
isShowing: Binding<Bool>,
42+
type: ToastType,
43+
transitionOptions: ToastTransitionOptions,
44+
positionOptions: ToastPositionOptions,
45+
onTap: (() -> Void)? = nil
46+
) {
47+
_isShowing = isShowing
48+
self.type = type
49+
self.transitionOptions = transitionOptions
50+
self.positionOptions = positionOptions
51+
self.onTap = onTap
52+
}
53+
54+
public func body(content: Content) -> some View {
55+
ZStack {
56+
content
57+
ZStack(alignment: positionOptions.position.alignment) {
58+
Color.clear
59+
toastView
60+
.padding(.leading, positionOptions.padding.left)
61+
.padding(.top, positionOptions.padding.top)
62+
.padding(.trailing, positionOptions.padding.right)
63+
.padding(.bottom, positionOptions.padding.bottom)
64+
}
65+
}
66+
}
67+
}
68+
69+
private extension ToastModifier {
70+
@ViewBuilder
71+
var toastView: some View {
72+
if isShowing {
73+
Group {
74+
switch type {
75+
case let .default(options):
76+
defaultToastView(options)
77+
case let .custom(view):
78+
view
79+
}
80+
}
81+
.onTapGesture {
82+
if let action = onTap {
83+
withAnimation {
84+
action()
85+
}
86+
} else {
87+
// If no action is defined, tapping on any part of the toast will dismiss it
88+
withAnimation {
89+
isShowing = false
90+
}
91+
}
92+
}
93+
.onAppear {
94+
DispatchQueue.main.asyncAfter(deadline: .now() + transitionOptions.duration) {
95+
withAnimation {
96+
isShowing = false
97+
}
98+
}
99+
}
100+
.transition(
101+
transitionOptions.transition
102+
.animation(transitionOptions.animation)
103+
)
104+
}
105+
}
106+
107+
func defaultToastView(_ options: DefaultToastOptions) -> some View {
108+
HStack(alignment: .center) {
109+
VStack(alignment: options.message.alignment.horizontal) {
110+
messageView(options.message)
111+
if let secondaryMessage = options.secondaryMessage {
112+
messageView(secondaryMessage)
113+
}
114+
}
115+
Spacer()
116+
if let button = options.trailingButton {
117+
Button {
118+
withAnimation {
119+
isShowing = false
120+
}
121+
} label: {
122+
Image(systemName: button.imageSystemName)
123+
.resizable()
124+
.frame(width: button.size.width, height: button.size.height)
125+
.tint(button.color)
126+
}
127+
}
128+
}
129+
.padding(.leading, options.internalPadding.left)
130+
.padding(.top, options.internalPadding.top)
131+
.padding(.trailing, options.internalPadding.right)
132+
.padding(.bottom, options.internalPadding.bottom)
133+
.background(options.background.color)
134+
.cornerRadius(options.background.cornerRadius)
135+
}
136+
137+
func messageView(_ message: DefaultToastOptions.Message) -> some View {
138+
Text(message.text)
139+
.foregroundColor(message.color)
140+
.font(message.font)
141+
.frame(alignment: message.alignment)
142+
}
143+
}
144+
145+
/// Possible types of toasts.
146+
public enum ToastType {
147+
/// The default toast, based on some configurable options.
148+
case `default`(options: DefaultToastOptions)
149+
/// Displays a custom view.
150+
case custom(_ view: AnyView)
151+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// ToastPositionOptions.swift
3+
//
4+
//
5+
// Created by Manu on 01/03/2023.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
public struct ToastPositionOptions {
12+
public enum Position {
13+
case bottom, top
14+
15+
var alignment: Alignment {
16+
switch self {
17+
case .bottom: return .bottom
18+
case .top: return .top
19+
}
20+
}
21+
}
22+
23+
let position: Position
24+
let padding: UIEdgeInsets
25+
26+
public init(
27+
position: Position = .top,
28+
padding: UIEdgeInsets = .zero
29+
) {
30+
self.position = position
31+
self.padding = padding
32+
}
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// ToastTransitionOptions.swift
3+
//
4+
//
5+
// Created by Manu on 01/03/2023.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
public struct ToastTransitionOptions {
12+
let duration: TimeInterval
13+
let transition: AnyTransition
14+
let animation: Animation
15+
16+
public init(
17+
duration: TimeInterval = 4,
18+
transition: AnyTransition = .opacity.combined(with: .move(edge: .top)),
19+
animation: Animation = .default
20+
) {
21+
self.duration = duration
22+
self.transition = transition
23+
self.animation = animation
24+
}
25+
}

0 commit comments

Comments
 (0)