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.
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.
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
}
}
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
The LoadingModifierType provides some different loading options with configurable parameters.
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.
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()
}
}
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() }
}
1-redacted.mov
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
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() }
}
2-custom-indicator.mov
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.
3-different-loadings.mov
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.
4-custom-views.mov
5-loadingsdemo_noMVqG5P.mp4
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.
The ViewStateController object also provided an array of Strings (modifyingIds):
/// Use this property when you need to make changes to specific parts of your view.
/// Example: When you want to display a ProgressView while deleting an item from a list.
public var modifyingIds: [String]?
It's usage is really simple:
func listView(_ pokemons: [Pokemon]) -> some View {
VStack {
ForEach(pokemons) { pokemon in
HStack {
Text(pokemon.name)
Spacer()
trailingView(for: pokemon.id)
}
Divider()
}
}
}
@ViewBuilder
func trailingView(for id: String) -> some View {
if let modifyingIds = controller.modifyingIds, modifyingIds.contains(id) {
// If the id is in the `modifyingIds` array, it means someone is modifying it.
// So we display a loading indicator.
ProgressView()
} else {
// Otherwise, we display a remove button.
Button("Remove", role: .destructive) {
Task {
// Tapping the button triggers an async call to remove the pokemon.
await removePokemon(id: id)
}
}
}
}
func removePokemon(id: String) async {
withAnimation {
// We add the `id` to the array.
controller.modifyingIds = (controller.modifyingIds ?? []) + [id]
}
// We simulate an async call to the backend. (1 second sleep)
try? await Task.sleep(nanoseconds: 1_000_000_000)
if let latestInfo = controller.latestInfo {
let updatedInfo = latestInfo.filter { pokemon in
pokemon.id != id
}
withAnimation {
// Then we update the state
controller.setState(.loaded(updatedInfo))
// And remove the id from the modifying array
controller.modifyingIds?.removeAll(where: { $0 == id })
}
}
}
struct Pokemon: Identifiable {
let id: String
let name: String
}
Screen.Recording.2023-03-16.at.17.41.25.mov
For DEBUG builds, there is a view extension that you can apply to any view, that lets you modify the state of the controller with ease.
/// Applies the debug state modifier to the view.
/// By tapping 3 times on the view, a modal will be displayed with options to debug
/// the state of the controller.
extension View {
public func debugState<Info>(
controller: Binding<ViewStateController<Info>>,
mockInfo: Info
) -> some View {
#if DEBUG
// Only apply the debug state modifier in debug builds
self.modifier(DebugStateModifier(controller: controller, mockInfo: mockInfo))
#endif
}
}
Usage:
someView
.debugState(controller: $controller, mockInfo: someMockInfo)
Screen.Recording.2023-04-11.at.12.09.35.mov
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.
toast.mp4
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)
)
When you want to use the same modifier, and present the toast/snack bar multiple times, you need to remember to first hide the one that you were showing (in case it's on the screen), and then display the new one:
Button("Toast") {
if displayToast {
// If the toast was being displayed, trigger the hide action.
displayToast = false
}
// And then trigger the show action.
withAnimation {
displayToast = true
}
}
This will ensure that the second
toast, gets rendered on the screen for the full amount of seconds (in the transitionOptions.duration property) before being dismissed.
To properly check that your code is working, you can uncomment the print("😄 Timer tick. Elapsed seconds: \(elapsedSeconds)")
line, and check the Xcode console.
snackbar.mp4
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
).