If you’ve been a software developer for a while, you’ve likely heard about the Keep it Simple, Stupid (KISS) design principle. It suggests that our systems should be implemented in the simplest way possible across all application layers: persistence, services, business logic, and the UI.
For all non-user facing app layers, the principle seems perfectly clear. We prefer to work with straightforward and simple data storage, domain models, and so on.
However, when it comes to the UI layer, things become less clear. Developers aren’t responsible for designing application screens. Should we decide how many buttons a view should have? How much text? Choose a leading icon? We could try, but…
Certainly not! That’s the job of the designer or UX specialist, and we must trust that they’ve done their best. However, what we can and should do is ensure the logic that operates the view is simple. In fact, it should be dead simple. The view should not “know” anything about the rest of the app. It should be provided with all the data it needs to render itself and handle user feedback, and nothing more!
In other words: How can we create simple (stupid) views in SwiftUI apps? And how can we maintain their simplicity?
Prefer listening over reading?
Every view is “born” simple…
Consider a typical “No Network” screen. It’s a view displayed to the user when an internet connection is lost. Chances are, most of us have implemented such a screen at some point.
The user interface requirements are straightforward:
- A lead icon – a visual depiction of poor network reception.
- A title label – ensure it’s easily distinguishable.
- A description text – this provides additional context to the error.
- A Retry button – this allows the user to verify if the network connection has been reestablished.
Let’s implement the view then:
struct SimpleErrorView: View {
@StateObject var viewModel: SimpleErrorViewModel
var body: some View {
ZStack {
// A loading indicator:
if viewModel.isLoading {
LoaderView()
}
VStack(...) {
…
// Lead icon:
icon.leadIcon()
// Title text:
Text("No network!")
.errorViewTitle()
// Description text:
Text("I'm unable to connect to the Internet...")
.errorViewDescription()
…
/// A check connection button:
PrimaryButton(label: "Check connection") {
await viewModel.checkConnection()
}
}
}
}
}
For the sake of code readability, I implemented views styling in dedicated modifiers, e.g. leadIcon() or errorViewTitle().
A matching View Model could look like this:
final class SimpleErrorViewModel: ObservableObject {
/// A flag indicating if any actions are performed in the background.
@Published var isLoading: Bool = false
…
@MainActor func checkConnection() async {
isLoading = true
let result = await networkConnectionChecker.checkNetworkConnectivity()
isLoading = false
if result {
popOrDismiss()
}
}
}
The view works like a charm. Good work!
... it just rarely stays that way for long.
Life is never that simple, right? Because the users were so “fond” of the View, the business also wants to display it when the backend is unresponsive 🤣 😁 🤣. However, this time, a different action should be performed when the Retry button is tapped, and a localised description of the error should be displayed. After all, there can be various reasons why the backend is down, such as planned maintenance, unplanned maintenance (or, in other words, a failure), and so on.
Generally, we have two options:
- Create a new view specifically to display this type of error (which may result in unnecessary code duplication).
- Make our existing view more configurable.
As responsible developers, we naturally want to avoid duplicating code. So, how can we make our static View display different types of errors? The solution is simple – make it configurable and pass the appropriate error to the View:
struct MultiErrorView: View {
@StateObject var viewModel: MultiErrorViewModel
var body: some View {
…
// Lead icon:
icon.leadIcon()
// Title text:
Text(title)
.errorViewTitle()
// Description text:
Text(description)
.errorViewDescription()
…
/// A check connection button:
PrimaryButton(label: buttonLabel) {
await handlePrimaryButtonTapped()
}
…
}
}
}
where:
private extension MultiErrorView {
var icon: Image {
switch viewModel.error {
case .serverMaintenance:
return LeadIcon.backendMaintenance.image
case .serverError:
return LeadIcon.backendIssue.image
default:
return LeadIcon.network.image
}
}
var title: String {
switch viewModel.error {
case .serverMaintenance:
return "The server is under maintenance"
case .serverError:
return "Unexpected server issue"
default:
return "No network!"
}
}
...
var buttonLabel: String {
switch viewModel.error {
case .serverMaintenance:
return "Refresh backend status"
case .serverError:
return "Try again"
default:
return "Check connection"
}
}
func handlePrimaryButtonTapped() async {
switch viewModel.error {
case .serverMaintenance, .serverError:
await viewModel.checkBackendStatus()
default:
await viewModel.checkConnection()
}
}
}
And the View Model:
final class MultiErrorViewModel: ObservableObject, Navigator {
…
/// A network error.
@Published var error: NetworkError
…
@MainActor func checkConnection() async {
isLoading = true
let result = await networkConnectionChecker.checkNetworkConnectivity()
isLoading = false
if result {
popOrDismiss()
}
}
@MainActor func checkBackendStatus() async {
isLoading = true
let status = await backendStatusChecker.checkBackendStatus()
isLoading = false
switch status {
case let .issueDetected(error):
self.error = error
case .ok:
popOrDismiss()
}
}
}
Well done! Another day, another victory!
The business strikes back!
Unfortunately, change has occurred once more, adding to our earthly challenges… The business truly is “in love” with the Error View! They find it incredibly useful! Now, they want to use the View to remind users about the availability of the app update.
Therefore, let’s modify the View to accommodate these new requirements:
struct VerySmartErrorView: View {
@StateObject var viewModel: VerySmartErrorViewModel
var body: some View {
…
/// A secondary action button:
if let secondaryButtonLabel {
SecondaryButton(label: secondaryButtonLabel) {
await handleSecondaryButtonTapped()
}
}
…
}
}
}
Just added another button, but the true “magic” happens in the View extension:
private extension VerySmartErrorView {
var icon: Image {
if let error = viewModel.error {
switch error {
case .serverMaintenance:
return LeadIcon.backendMaintenance.image
case .serverError:
return LeadIcon.backendIssue.image
default:
return LeadIcon.network.image
}
} else if let updateStatus = viewModel.appUpdateAvailabilityStatus {
switch updateStatus {
case .notNeeded:
return LeadIcon.updateNotAvailable.image
case .available:
return LeadIcon.updateAvailable.image
case .required:
return LeadIcon.updateRequired.image
}
}
return Image(uiImage: UIImage()) // Empty image
}
var title: String {
if let error = viewModel.error {
switch error {
case .serverMaintenance:
return "The server is under maintenance"
case .serverError:
return "Unexpected server issue"
default:
return "No network!"
}
} else if let updateStatus = viewModel.appUpdateAvailabilityStatus {
switch updateStatus {
case .notNeeded:
return "Your app is up to date!"
case .available:
return "App update is available"
case .required:
return "Your app is no longer supported"
}
}
return ""
}
…
}
And the View Model:
final class VerySmartErrorViewModel: ObservableObject, Navigator {
/// A flag indicating if any actions are performed in the background.
@Published var isLoading: Bool = false
/// A network error.
@Published var error: NetworkError?
/// An app availability status.
@Published var appUpdateAvailabilityStatus: AppUpdateAvailabilityStatus?
…
init(
error: NetworkError?,
appUpdateAvailabilityStatus: AppUpdateAvailabilityStatus?,
…
) {
…
if let error = error {
self.error = error
self.appUpdateAvailabilityStatus = nil
} else if let appUpdateAvailabilityStatus = appUpdateAvailabilityStatus {
self.appUpdateAvailabilityStatus = appUpdateAvailabilityStatus
self.error = nil
} else {
logInvalidInitialisationError()
}
}
…
@MainActor func goToStore() async {
let url = AppUrl.appStore.url
if urlOpener.canOpenURL(url) {
urlOpener.open(url, options: [:], completionHandler: nil)
} else {
print("Cannot open this link. Are you running the app on simulator?")
}
}
}
Once again, it works great! Notice how quickly our View is growing and becoming more “intelligent”! Initially, the View couldn’t update its texts. But now, it “understands” everything about network errors and application update statuses! It only took a few sprints to “educate” it!
As a “parent”, you must be proud! You must be surely holding your breath to see what it would (have to) learn next…
However, in all seriousness, let’s take a moment to examine what our View needs to “know” in order to do its job properly:
- 🛜 How to interpret different types of network errors.
- 📲 How to read various app update availability states.
- 🧠 The action that should be triggered when a button is tapped.
- 🎆 The lead icon that should be displayed.
- 💬 The texts that need to be shown.
This might seem like a lot for a “simple” view, perhaps too much.
However, remember that this is still fully testable code. All dependencies can be injected, all the ViewModel states can be verified, and so on. Despite this, it’s not difficult to envision a situation where the View could be in a conflicting internal state. For instance, what if an app update status and a network error are passed simultaneously?
Furthermore, no one can guarantee there won’t be any additional change requests. What if the business requires jailbroken device detection too? Should we drastically split the View into four separate ones? Or is it time to update our CVs?
Not at all! Let’s just make our view universal!
How to implement universal views?
Let’s break down the components of an “ideal“, generic app error screen:
- An icon – a simple image that represents the error that occurred.
- A title label – text that describes the type of error.
- A description label – optional text that further describes the error and/or explains the user’s options.
- A progress indicator – a view displayed to inform the user that the app is performing background operations.
- A primary button – the main Call To Action (CTA) button, which typically allows the user to retry the action that caused the error.
- A secondary button – generally a cancel/close button, which allows the user to exit the screen.
As you can observe, some subviews may not be displayed. However, all of them should be configurable from outside the View. The question is, what type of data should such a configuration include?
Perhaps we could use an enumeration? We could create a custom enum that includes all the issues we want the View to cover. It seems like a good idea, but is it? If we pass an enum, our view would need to understand its various cases and how they convert into user-facing data, such as the title, description, etc.
Why not keep it really simple?
To display an icon that represents a specific concept, we only need an image. For the title and description labels, a basic or attributed string is sufficient. The primary and secondary buttons also require a string. All we need is some text to exhibit as a button label.
Let’s write it down then:
struct ErrorViewConfiguration: Equatable {
let title: String
let description: String
let icon: Image
let showPreloader: Bool
let primaryButtonLabel: String?
let secondaryButtonLabel: String?
}
Hold on a second… The View must still analyse the configuration to determine, for instance, if it needs to display the secondary action button. That involves some form of logic. Yet, our aim was to make the view “simple“…
No worries – we just covered that! When we talked about removing unnecessary logic from the view, we were referring to business logic. It’s okay for the View to determine, for example, which subviews to show or hide, provided that:
- The data used for this decision lacks any business context.
- The decision process is extremely simple, like conducting a boolean check or unwrapping an optional.
Let’s rewrite the View to make use of the configuration:
struct ErrorView<ViewModel>: View where ViewModel: ErrorViewModel {
@StateObject var viewModel: ViewModel
var body: some View {
…
if viewModel.viewConfiguration.showPreloader {
LoaderView()
}
…
// Lead icon:
icon.leadIcon()
// Title text:
Text(viewModel.viewConfiguration.title)
.errorViewTitle()
// Description text:
Text(viewModel.viewConfiguration.description)
.errorViewDescription()
Spacer()
/// Primary CTA button:
if let primaryLabel = viewModel.viewConfiguration.primaryButtonLabel {
PrimaryButton(label: primaryLabel) {
await viewModel.onPrimaryButtonTap()
}
}
…
}
}
Now, that’s what I call a simple view! All it needs to “know” in order to render itself properly is the configuration, which consists only of simple types – strings, images, etc. Literally, the worst possible mistake we can make here is a typo.
How to “feed” the universal view?
Let’s explore how to generate the appropriate configuration for the View and manage user actions. To do this, we’ll need to make slight adjustments to our trusted View Model.
Let’s start with a proper abstraction:
protocol ErrorViewModel: ObservableObject {
/// An ErrorView configuration. To be implemented as a @Published property.
var viewConfiguration: ErrorViewConfiguration { get }
var viewConfigurationPublished: Published<ErrorViewConfiguration> { get }
var viewConfigurationPublisher: Published<ErrorViewConfiguration>.Publisher { get }
/// A primary button tap callback.
func onPrimaryButtonTap() async
/// A secondary button tap callback.
func onSecondaryButtonTap() async
}
You might be wondering: why do we need to abstract the View Model? Can’t we simply pass a View Model implementation directly?
Yes and no 😉
While we aim for the View to be universal, a View Model needs to be highly specialized.
Ideally, each specific application error or business case would have its own View Model. The most effective way to connect these disparate View Models with the View is by introducing a protocol between them. This is a practical application of the Dependency Inversion Principle.
Unless your app can leverage the Observation framework introduced in iOS 17, the most common method to expose a state to the View is through either the @StateObject or @ObservedObject property wrapper. However, there’s an issue – the compiler will generate an error if we declare:
We can get around it by declaring the View Model reference as a generic:
@StateObject var viewModel: ViewModel
By doing so, we accidentally archived something else: we made our View “officially” generic.
struct ErrorView<ViewModel>: View where ViewModel: ErrorViewModel
Now, we can initialize a concrete, specialized View Model, bound to our universal View:
func makeNoNetworkErrorView(presentationMode: PresentationMode) -> ErrorView<NoNetworkErrorViewModel> {
let connectionChecker = DefaultNetworkConnectionChecker()
let viewModel = NoNetworkErrorViewModel(
router: router,
presentationMode: presentationMode,
networkConnectionChecker: connectionChecker
)
return ErrorView(viewModel: viewModel)
}
or:
func makeBackendErrorView(error: NetworkError, presentationMode: PresentationMode) -> ErrorView<BackendUnavailableErrorViewModel> {
let viewModel = BackendUnavailableErrorViewModel(
router: router,
error: error,
presentationMode: presentationMode,
backendStatusChecker: PlaceholderBackendStatusChecker()
)
return ErrorView(viewModel: viewModel)
}
… and that’s exactly what we wanted!
There are two minor issues to address:
- Which component should be responsible for generating the View configuration?
- How and where should user actions (e.g., tapping a CTA button) be handled?
I believe you already know the answer. 😉
Naturally, these responsibilities should be placed in an object that understands the business context and knows how to interpret user actions: the View Model. We can create a dedicated View Model to handle a specific application error or business case. It should be able to generate the appropriate configuration for the View and manage user feedback, such as tapping a secondary action button. It’s as simple as that!
/// A view model for No Network app error screen.
final class NoNetworkErrorViewModel: ErrorViewModel, Navigator {
/// - SeeAlso: ErrorViewModel.viewConfiguration
@Published var viewConfiguration: ErrorViewConfiguration
var viewConfigurationPublished: Published<ErrorViewConfiguration> { _viewConfiguration }
var viewConfigurationPublisher: Published<ErrorViewConfiguration>.Publisher { $viewConfiguration }
/// - SeeAlso: Navigator.router
let router: any NavigationRouter
/// - SeeAlso: Navigator.presentationMode
let presentationMode: PresentationMode
private let networkConnectionChecker: NetworkConnectionChecker
init(...) {
…
viewConfiguration = .noNetwork
}
/// - SeeAlso: ErrorViewModel.onPrimaryButtonTap()
@MainActor func onPrimaryButtonTap() async {
viewConfiguration = viewConfiguration.showingPreloader(true)
let result = await networkConnectionChecker.checkNetworkConnectivity()
viewConfiguration = viewConfiguration.showingPreloader(false)
if result {
popOrDismiss()
}
}
}
and:
var noNetwork: ErrorViewConfiguration {
ErrorViewConfiguration(
title: "No network!",
description: "I'm unable to connect to the Internet.\nUse the button below to check the connection.",
icon: LeadIcon.network.image,
showPreloader: false,
primaryButtonLabel: "Check connection",
secondaryButtonLabel: nil
)
}
Handling additional cases
What should we do when the business requests to manage another application error or use case?
The process is straightforward:
- Create a View Model to manage that case, ensuring it conforms to the ErrorViewModel protocol.
- Prepare a lead icon, as well as all the texts and button labels we want to display to the user.
- Create a View configuration that represents these elements.
- Implement the handling of user actions in the View Model (if there are any that need to be managed).
And… that’s it! You don’t need to modify the View unless the use case requires it, for example: adding an additional button to handle a tertiary user action. In such a case, it’s always worth taking a step back and analyzing if our View wouldn’t have too many responsibilities.
As a result of implementing these changes, we achieved:
- A simple, configurable, and reusable view…
- …that can be fully controlled by an injectable view model, capable of handling specific use cases or app errors, such as backend maintenance.
- Provided that this view model conforms to the ErrorViewModel protocol, no changes to the view are necessary to handle new use cases.
- Lastly, we can easily create a dedicated view model, providing fixture data to our view in the Xcode Preview. This allows us to compare multiple renders of the view side-by-side.
How to ensure no regressions?
It sounds good, but how can I ensure the View remains unchanged in the future?
The straightforward answer is: you can’t.
Eventually, the business might request a feature that necessitates a change in the View structure.
So, how can we ensure that implementing a change won’t break the view? How can we maintain the same user experience for existing use cases?
By having a comprehensive Unit Tests suite.
But how can we cover SwiftUI views with Unit Tests? These are just descriptions of how a view should look and behave, not actual views that can be tested…
You’re absolutely correct – it’s nearly impossible to thoroughly test SwiftUI views with traditional Unit Tests. So, how can we confirm that our view is rendered consistently when provided with a particular configuration? The solution is snapshot tests, a type of integration test. The test equips a view with a fixed configuration, renders it, takes a snapshot, and compares it with a reference each time the tests run. For more details, take a look at my blog post dedicated to snapshot tests. This way, we can ensure the image that a user sees on their device screen remains the same, even with changes to the View’s structure.
Additionally, it’s valuable to test the View Models. Simple Unit Tests are suitable for this purpose. The clear candidates for testing include: generation of the view configuration and handling user feedback.
Final thoughts
Every view is “born” simple – it’s just so difficult to keep it that way…
That’s why it’s essential to make the reusable views as universal as possible. These generic views rarely change, since the business logic that composes their content is located elsewhere. We can have our reusable view rely on a similarly universal API to communicate with such logic through a View Model protocol. Furthermore, the View doesn’t need to know about the View Model’s implementation details. This allows us to create a separate View Model to handle each business case. We can easily add more cases in the future!
Moreover, the beauty of Snapshot Tests allows us to ensure that we won’t introduce breaking changes to our View if we ever need to modify it.
Finally, even though the code samples are written in SwiftUI, the approach we discussed is equally effective in the UIKit world.
Indeed, it’s a pretty powerful way to make our lives easier:
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2imageAudio version generated with TTS Maker