ViewStateController
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.
There is an Example app available here, where most of the configuration options can be tweaked.
ViewStateController Object
The ViewStateController struct is the one that contains the array of historical ViewStates and has computed properties that will be used by the ViewStateModifier to determine what to do.
isInitialLoading
: Returns true only if loading state was set once and there hasn’t been errors or info yet.isLoading
: Returns true if state is loading.latestValidInfo
: Info associated to the last timeloaded
state was set. Nil if there has been an error after the latest info.latestInfo
: Info associated to the last timeloaded
state was set, disregarding if there has been an error afterwards.latestValidError
: Info associated to the last timeerror
state was set. Nil ifinfo
has been loaded after the latest error.latestError
: Info associated to the last time loadederror
was set, disregarding if there has been an error afterwards.latestNonLoading
: Returns the latest informational state (info, or error) if exists. Nil otherwise.
There are also two mutating methods:
setState(_ state: ViewState<Info>)
: Sets the new state into the states array.reset()
: Resets everything.
ViewStateModifier
The ViewStateModifier is a ViewModifier that uses the given ViewStateController and configurable options to automatically update the state of a view.
The code of the modifier is pretty straight forward:
func body(content: Content) -> some View {
if controller.isInitialLoading {
// Initial loading modifier displayed on the initial loading state.
content.modifier(initialLoadingModifier)
} else if let info = controller.latestValidInfo {
// If we have valid info loaded we display it:
loadedView(info)
.if(controller.isLoading) { view in
// If we are on a subsequent loading, we add the modifier.
view.modifier(loadingAfterInfoModifier)
}
} else if let error = controller.latestValidError {
// If we have a value error we display it:
errorView(error)
.if(controller.isLoading) { view in
// If we are on a subsequent loading, we add the modifier.
view.modifier(loadingAfterErrorModifier)
}
} else {
// Otherwise, we display the initial content.
content
}
}
withViewStateModifier method
The withViewStateModifier
method, is just a convenience way to add the ViewStateModifier to any view:
/// Adds a view state modifier that can display different views depending on the state of a `ViewStateController`.
/// - Parameters:
/// - controller: The `ViewStateController` that controls the state of the view.
/// - indicatorView: The view to show when the view is loading.
/// - initialLoadingType: The type of loading indicator to show when the view is initially loading.
/// - loadedView: The view to show when the view is not loading and has valid information.
/// - loadingAfterInfoType: The type of loading indicator to show when the view is loading after it has already
/// displayed valid information.
/// - errorView: The view to show when the view has an error.
/// - loadingAfterErrorType: The type of loading indicator to show when the view is loading after it has displayed
/// an error.
func withViewStateModifier<Info, IndicatorView: View, LoadedView: View>(
controller: ViewStateController<Info>,
indicatorView: IndicatorView = ProgressView(),
initialLoadingType: LoadingModifierType = .material(),
loadedView: @escaping (Info) -> LoadedView,
loadingAfterInfoType: LoadingModifierType = .horizontal(),
errorView: @escaping (Error) -> ErrorView,
loadingAfterErrorType: LoadingModifierType = .overCurrentContent(alignment: .trailing)
) -> some View
LoadingModifierType
The LoadingModifierType provides some different loading options with configurable parameters.
Usage
The ideal usage would be to:
- Decide your strategy for
initialLoading
,loadingAfterInfo
,errorView
, andloadingAfterError
states. - Create the placeholder view (The one that will be there before the initial loading). (This could be an
EmptyView()
or theloadedView
with a.redacted
modifier). - Create the
loadedView
. - Decide if the error state will have a retry action or not.
Examples with code samples
Let’s take a look at how we can use it in our views:
We will be using this view and controller in our examples:
@State var controller: ViewStateController<User> = .init()
...
struct User {
let name: String
let age: Int
let emoji: String
}
...
func loadedView(user: User) -> some View {
HStack(spacing: 8) {
ZStack {
Circle()
.frame(width: 50, height: 50)
.foregroundColor(Color.gray.opacity(0.2))
Text(user.emoji)
}
VStack(alignment: .leading, spacing: 8) {
Text("Name: \(user.name)")
Text("Age: \(user.age.description)")
}
Spacer()
}
}
Redacted
loadedView(user: .init(name: "Placeholder", age: 99, emoji: "")) // 1. Create a placeholder view
.redacted(reason: .placeholder) // 2. Use redacted to hide the texts
.withViewStateModifier( // 3. Apply view modifier
controller: controller
) { user in
loadedView(user: user) // 4. Provide the view for the loaded information
} errorView: { _ in // 5. Provide an error view
.init { setLoading() }
}
Since we are not changing the values for the loading types it’s using the default values:
.material()
for the initial loading.horizontal()
for the loading after info type.overCurrentContent(alignment: .trailing)
for the loading after error type
Changing the indicator view
If you have a custom progress view, you can use it in the indicatorView
parameter. Example from this post:
loadedView(user: .init(name: "Placeholder", age: 99, emoji: ""))
.redacted(reason: .placeholder)
.withViewStateModifier(
controller: controller,
indicatorView: SpinnerProgressView(color: .green)
) { user in
loadedView(user: user)
} errorView: { _ in
.init { setLoading() }
}
Changing Loading types
By changing the initialLoadingType
, loadingAfterInfoType
, or loadingAfterErrorType
you can provide different ways of displaying the loading states.
You can find a list of the possible options here
Example:
loadedView(user: .init(name: "Placeholder", age: 99, emoji: ""))
.redacted(reason: .placeholder)
.withViewStateModifier(
controller: controller,
indicatorView: SpinnerProgressView(color: .purple), // Mark 1
initialLoadingType: .vertical(option: .bottom, alignment: .center), // Mark 2
loadedView: { user in
loadedView(user: user)
},
loadingAfterInfoType: .horizontal(option: .leading, contentOpacity: 0.3, alignment: .center, spacing: 32), // Mark 3
errorView: { _ in .init { setLoading() } },
loadingAfterErrorType: .overCurrentContent(contentOpacity: 0.5, alignment: .bottomTrailing) // Mark 4
)
In this example:
- Mark 1: We are changing the indicator view to use a custom one.
- Mark 2: We are changing the initial loading type, to use a VStack with the indicator at the bottom and center alignment.
- Mark 3: We are changing the loading after info type, to use an HStack with the indicator in the leading position, 0.3 as the opacity for the content, center alignment, and 32 of spacing.
- Mark 4: We are changing the loading after error type to use the
overCurrentContent
type with 0.5 for the content opacity, and bottomTrailing alignment.
Using Custom Views
Let’s say that the screen/view you are working on requires some special views for each state. You could use the .custom
type for any of the states:
EmptyView() // Mark 1
.withViewStateModifier(
controller: controller,
indicatorView: SpinnerProgressView(color: .orange), // Mark 2
initialLoadingType: .custom( // Mark 3
VStack {
Text("This is the initial loading")
SpinnerProgressView(color: .blue, size: 50, lineWidth: 5)
}.asAnyView()
),
loadedView: { user in
loadedView(user: user)
},
loadingAfterInfoType: .custom( // Mark 4
HStack {
Image(systemName: "network")
Text("I got info, but I am loading again")
SpinnerProgressView(color: .black)
}.asAnyView()
),
errorView: { error in // Mark 5
.init(type: .custom(
VStack {
Text("I got an error")
Text(error.localizedDescription)
}
.foregroundColor(.red)
.asAnyView()
))
},
loadingAfterErrorType: .custom( // Mark 6
HStack {
Image(systemName: "network")
Text("I got info, but I am loading again")
SpinnerProgressView(color: .red)
}
.foregroundColor(.red)
.asAnyView()
))
In this example:
- Mark 1: We are using an EmptyView as the placeholder view.
- Mark 2: We are changing the indicator view to use a custom one.
- Mark 3: We are changing the initial loading type, to use a custom view.
- Mark 4: We are changing the loading after info type, to use a custom view.
- Mark 5: We are changing the error view, to use a custom view.
- Mark 6: We are changing the loading after error type, to use a custom view.
Demo: Loading Type Options
In this video, we are tweaking around some properties and pass them to the withViewStateModifier
to demonstrate the different loading and error states that comes for free. Everything is configurable, and there is also the ability to provide custom views for loading states, the indicator, and the error states.
The app used for this video can be downloaded from this repository.
Toasts
Similar to the LoadingModifier, there is also a ToastModifier that let’s you present toast/snack bars/custom views in the screen with a set of configurable parameters.
Examples with code samples
SnackBar
Let’s say we want a snack bar to be displayed at the bottom of the screen, we can achieve that with these lines of code:
@State private var displayToast: Bool = false
...
YourView
.toast(
isShowing: $displayToast,
type: .snackBar(options: .init(message: .init(text: "Hey There"))),
transitionOptions: .init(transition: .move(edge: .bottom).combined(with: .opacity)),
positionOptions: .init(position: .bottom)
)
Internal Project Tools
Formatter
To run the formatter, just run the following command from the root of the repository:
swiftformat . --config "Sources/.swiftformat" --swiftversion 5.7
You need to have SwiftFormat
installed (brew install swiftformat
).