Quantcast
Viewing all articles
Browse latest Browse all 3

Why should you KISS your SwiftUI views? Explained with memes

What are KISS SwiftUI Views?

If you’ve been a software developer long enough, you’ve surely heard about the Keep it Simple, Stupid (KISS) design pattern, right? It states that we should implement our systems the simplest way possible across all the application layers: persistence, services, business logic and the UI. 

For all non-user facing app layers the principle seems perfectly understandable. After all, we prefer to work with a clear and simple data storage, domain model, etc.

But when we get to the UI layer it gets murky. Developers are not responsible for designing application screens. Should we dictate how many buttons a view should have? How much text? Select a leading icon? We could try, but…

Of course not! That’s the designer’s / UX specialist’s job and we have to trust they did their best. What we can (and should) do however, is to make sure the logic that operates the view is simple. Dead simple actually. That the view does not “know” anything about the rest of the app. That it’s provided 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 keep them this way?

Every view is “born” simple…

All the sources used in the following examples can be found on my Github.

Imagine a typical “No Network” screen. It’s a view shown to the user every time an Internet connection has been lost. I bet most of us have implemented such a screen one way or another. The UI requirements are simple:

  • A “lead” icon – a visual representation of poor network reception.
  • title label – make sure it’s properly distinguished.
  • description text – to add some more context to the error.
  • A “retry” button – allowing the user to check if the network connection has been restored.

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 to 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, is it? Because the users liked the View so much Image may be NSFW.
Clik here to view.
🤣
Image may be NSFW.
Clik here to view.
😁
Image may be NSFW.
Clik here to view.
🤣
, the business wants to
show it when the backend is unresponsive as well. This time however, we should perform a different action when the “Retry” button is tapped and display a localised description of an error. After all, the backend can be down due to many reasons: planned maintenance, unplanned maintenance (a.k.a. a failure), etc. 

In general, we have 2 options:

  • Create a new view, dedicated to showing this particular type of error (resulting in unnecessary code duplication)
  • Make our existing view configurable

Being a responsible developer, naturally we want to avoid duplicating code. But how can we make our static View display different types of errors? That’s easy – let’s make it configurable and pass a proper 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!

Alas, yet again the punishing “hand of change” strikes, contributing greatly to our earthly sorrows…

The business absolutely loves the Error View! They can’t get enough of it! Now they want to use the View to remind the users about the app update availability. Well, it’s too late to create separate views now…

We have to modify the View to meet the 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?")
        }
    }
}

Yet again, it works great! And look how fast our View is growing! How smart it’s becoming! Initially the View  couldn’t even update its texts and now it “knows” everything about network errors and application update statuses! And it took only a couple of sprints to “teach” it that!

As a parent, you must be proud! You must be surely holding your breath to see what it would (have to) learn next…

In all seriousness however, let’s take a big breath and take a look at what our View has to “know” about to do its job right now:

  • How to interpret received network error type.
  • How to read various app update availability states.
  • Which action should be triggered when tapping a button.
  • Which lead icon to show.
  • Which texts to display.

And the list goes on… That’s a lot for a “simple” view. Way too much actually.

Please bear in mind this is still fully testable code. All the dependencies are injectable, all the View Model states are verifiable, etc. And even so, we can easily imagine a situation when the View would be in a conflicting internal state. How about passing an app update status and a network error at the same time?

On top of that, nobody can guarantee there would be no further change requests. What would we do if the business requests to handle jailbroken device detection as well? Should we make a 180° turn and split the View into 4 separate ones? Or maybe it’s time to refresh a CV

Nothing of the sort! Let’s simply make our view universal!

How to implement universal views?

All the sources used in the following examples can be found on my Github.

Let’s take a step back and see if we can write down all the subviews making an “ideal”, generic app error screen:

  • An icon – a simple image representing an error that just occurred
  • A title label – a descriptive text representing an error type
  • A description label – an optional text further describing an error and / or explaining options available to the user.
  • A progress indicator – a view that would be shown to let the user know the app is performing some background operations.
  • A primary button – a primary Call To Actions (CTA) button. Usually allows the user to retry the actions that caused an error.
  • A secondary button – usually a cancellation / close button. Allows the user to dismiss the screen.

As you can see, some of the subviews might not be shown. All of them however should be configurable from outside the View. But exactly what kind of data should such a configuration contain? 

Maybe an enumeration? We could create a custom enum encompassing all the issues we wish to cover by the View. That seems to be a good idea, but is it really? Passing an enum would require our view to have knowledge about its different cases and how they translate to the user-facing data: title, description, etc.

How about keeping it really simple

What kind of data is required to show an icon representing a specific concept? Just an image. The title and description labels? A simple (or attributed) string. The primary and secondary buttons? A string as well. We just need some text to display 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?
}

Wait a minute… The View would still have to analyse the configuration to determine if it needs to show e.g. the secondary action button. That’s some kind of logic. And we wanted to make the view “stupid”…

No worries – we just did! When we discussed removing unnecessary logic from the view, we actually meant business logic. It’s ok for the View to determine e.g. which subvies to show / hide, for as long as:

  • The data used to make that call is without any business context
  • The decision process is deadsimple – like making 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” about to render itself properly is the configuration, consisting only of simple types – strings, images, etc. Literally, the worst kind of mistake we can make here is a typo.

How to “feed” the universal view?

Let’s take a look at how we can generate the correct configuration for the View and handle user actions. For that purpose, we’d need to “slightly” modify 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
}

At this point you might be thinking: but why do we need to abstract the View Model? Can’t we just pass a View Model implementation directly?

Yes and no Image may be NSFW.
Clik here to view.
😉
 

You see: as much as we want the View to be universal, a View Model has to be very specialised.
Ideally, we’d need a separate View Model to handle a specific application error / business case. Arguably, the only way to efficiently connect all these different View Models with the View is to introduce an abstraction between them. A protocol. Good, old Dependency Inversion Principle in action.

The best way to expose a state to the View is to use either: @StateObject or @ObservedObject property wrapper. But there is a problem – the compiler will complain 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 achieved one more thing: we made our View “officially” generic:

struct ErrorView<ViewModel>: View where ViewModel: ErrorViewModel

Now, we can initialise a concrete, specialised View Model, bound with 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 2 tiny matters to resolve:

  • What component should be responsible for generating View configuration?
  • How and where user actions (e.g. tapping a CTA button) should be handled?

I think you already know the answer Image may be NSFW.
Clik here to view.
😉

Naturally, we should place these responsibilities in an object that is aware of the business context and has knowledge how to interpret user actions – the View Model. And we can create a dedicated View Model to handle a specific application error / business case. So, it should be able to produce a proper configuration for the View and handle user feedback, e.g. tapping a secondary action button. Plain and simple!

/// 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 asks to handle another application error / use case?

That’s simple:

  • Create a View Model to handle that case, conforming to the ErrorViewModel protocol.
  • Prepare a lead icon, all the texts and button labels we wish to show to the user. 
  • Create a View configuration representing all that. 
  • Implement handling user actions in the View Model (if there are any to be handled). 

And… that’s it! You don’t need to modify the View unless the use case requires e.g. adding an additional button to handle tertiary user action. In such a case, it’s always worth taking a step back and analysing if our View would not have too many responsibilities. 

As a result of applying these few changes we achieved:

  • A simple, configurable and reusable View…
  • … that can be fully controlled by an injectable View Model, capable of handling a particular use case / app error, e.g. backend maintenance.
  • For as long as this View Model conforms to the ErrorViewModel protocol, no changes to the View are required to handle new use cases.
  • Finally, we can easily create a dedicated View Model, providing fixture data to our View in the Xcode Preview. This way we can e.g. compare multiple renders of the View side-by-side.

How to ensure no regressions?

It all sounds good, but how can I ensure the View stays unchanged in the future? 

The short answer is: you can’t

Sooner or later the business would ask you to implement a feature that would alter the View structure in some way. 

So how can we ensure we wouldn’t break the view while implementing the change? That it would look the same to the user when handling existing use cases?

Again, the answer is simple: by having a proper Unit Tests suite. 

But how can I cover SwiftUI views with Unit Tests? It’s just a description of how a view should look and behave, not an actual view I can test…

You are absolutely right – it’s almost impossible to extensively cover SwiftUI views with classical Unit Tests. So how can we ensure our View is rendered the same way whenever provided with a specific configuration? We’ll use snapshot tests! It’s a form of integration test. The test outfits a view with a fixture configuration, renders it, makes a snapshot and compares it with a reference each time the tests are executed. This way we can ensure an image a user can see on their device screen remains the same despite changes applied to the View structure

Unfortunately, as this post is waaaaay too long already, I won’t go into the implementation details of the snapshot tests suite. However, I’ll be doing a deep dive into different types of automated tests for iOS applications, so stay tuned for more details! 

On top of that, it’s worth testing the View Models as well. Good, old Unit Tests will suffice here. The obvious candidates to test would be: 

  • View configuration generation 
  • Handling of the user feedback

If you’d like to see some examples, take a look at the code samples on my Github.

Final thoughts

Every view is “born” simple – it’s just soooo difficult to keep it that way… 

That’s why it’s important to generalise the reusable views as much as possible. Such generic, universal views change surprisingly rarely, as the business logic composing the content they display is located elsewhere. We can easily make our universal View rely on the equally universal API to communicate with such a logic: a View Model protocol. On top of that, the View doesn’t need to know about the implementation details of the View Model, allowing us to create a separate one to handle each business case / app error: “no network”, an outdated app, etc. And we can add more cases in the future with ease!

Moreover, thanks to the beauty of Snapshot Tests, we can rest assured that we would not introduce breaking changes to our View should we ever need to modify it.

Finally, although the code samples are written in SwiftUI, the approach we discussed works in the UIKit world equally well. 

Indeed, pretty powerful way to make our lives easier:

Don’t miss any new blogposts!

[contact-form-7]

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 Image may be NSFW.
Clik here to view.
😎

They were not meant to offend anyone Image may be NSFW.
Clik here to view.
🙌

All copyrights belong to their respective ownersImage may be NSFW.
Clik here to view.
🧐

The thumbnail was generated with text2image Image may be NSFW.
Clik here to view.
❤
 

The post Why should you KISS your SwiftUI views? Explained with memes first appeared on Swift and Memes.


Viewing all articles
Browse latest Browse all 3

Trending Articles