Inside Photoroom
Improving the Loading Experience in SwiftUI
Vincent Pradeilles
Vincent PradeillesDecember 27, 2022

Intro

At Photoroom we strive to build an iOS app that offers the best user experience possible! Today I’m going to show you a pretty cool SwiftUI trick we came up with to improve the UX when you’re displaying a loader while a network call is happening.

The problem we had to solve

We’ve implemented a new feature called Magic Studio that creates an AI-generated scene around the photo of an object or a person.

When a user opens this feature, we first need to make a network call to load the list of all available scenes.

Our first approach was relatively straightforward: we were displaying a loader while the data was being loaded from the network:

Group {
    switch viewModel.viewState {
    case .loading:
        ProgressView()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    case .fetched:
        MagicStudioListView()
    case .error:
        MagicStudioErrorView(viewModel: viewModel)
    }
}
.task {
    await viewModel.fetchScenes()
}

However, when we started testing the feature, we quickly noticed that there was an issue with this approach!

A lot of times, the network call would complete very quickly, which meant that the loader would be displayed only for a very short amount of time.

This led to a UI that wasn’t very pleasant in terms of user experience, because the few frames where the loader was displayed gave the frustrating feeling of a UI that flashes:

How to solve the problem from a UX perspective

So we started looking for a solution to this problem!

My first idea, which wasn’t the best, was to make it so that the loader couldn’t be displayed for less than half a second, regardless of whether the network call would complete sooner.

This approach would have solved the problem of the flashing UI, however, it would have solved it at the cost of introducing an unnecessary delay to access the feature, which is less than ideal!

But fortunately, our design team had a better idea: instead of displaying the loader immediately, we would wait for one second to actually display it.

This way, when the network call goes fast, no loader would appear and the UI wouldn’t flash. And when the network call takes longer than usual, only then would we eventually display the loader.

How to implement the solution with SwiftUI

Now that we have the solution, all that’s left is to implement it using SwiftUI!

We want to implement a mechanism to delay the appearance of a View and as it turns out SwiftUI comes with the perfect tool for this: we need to implement a custom ViewModifier :

import SwiftUI

struct DelayAppearanceModifier: ViewModifier {
    @State var shouldDisplay = false

    let delay: Double

    func body(content: Content) -> some View {
        render(content)
            .onAppear {
                DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                    self.shouldDisplay = true
                }
            }
    }

    @ViewBuilder
    private func render(_ content: Content) -> some View {
        if shouldDisplay {
            content
        } else {
            content
                .hidden()
        }
    }
}

public extension View {
    func delayAppearance(bySeconds seconds: Double) -> some View {
        modifier(DelayAppearanceModifier(delay: seconds))
    }
}

The logic of this custom modifier is quite simple:

  • First, we store a boolean shouldDisplay to decide whether or not the view should be displayed

  • Then we give that boolean a default value of false

  • Finally, when the view is rendered for the first time, we schedule a piece of code that will set shouldDisplay to true to be executed after some delay

And now all that’s left to do is to actually use our new modifier delayAppearance(bySeconds:) on our ProgressView():

Group {
    switch viewModel.viewState {
    case .loading:
        ProgressView()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .delayAppearance(bySeconds: K.Durations.delayBeforeShowingLoader)
    case .fetched:
        MagicStudioListView()
    case .error:
        MagicStudioErrorView(viewModel: viewModel)
    }
}
.task {
    await viewModel.fetchScenes()
}

And that’s it, we can now test the new behavior of our UI, first with a fast network:

As you can see, the loader was never displayed, because the network call was completed in less than one second.

And now if we test with a much slower network:

This time the network call took more than one second to complete, so the loader was eventually displayed to let the user know that the feature is still loading.

Conclusion

I hope you've enjoyed this nice little UX trick to avoid displaying a loader when it would result in a subpar user experience!

While this might look like a detail, at Photoroom we aim to deliver the best image editing app available on iOS and we strongly believe that providing the best UX possible is a key part of achieving this goal.

Vincent Pradeilles
Vincent PradeillesSenior Software Engineer @ Photoroom

Keep reading

Packaging your PyTorch project in Docker
Eliot Andres
Mutagen tutorial: syncing files easily with a remote server
Eliot Andres
Make stable diffusion up to 100% faster with Memory Efficient Attention
Matthieu Toulemont
How we automated our changelog thanks to ChatGPT
Jeremy Benaim
Core ML performance benchmark iPhone 15 (2023)
Florian Denis
10 Tools to Ship an iOS App in 2 Weeks
Profile Picture of Matthieu Rouif
Matthieu Rouif
Building a fast cross-platform image renderer
Florian Denis
Making stable diffusion 25% faster using TensorRT
David Briand
Tales from Photoroom Hackathon Nº3
Eliot Andres
The value of values: What we learned from an afternoon spent drawing Axolotls
Lauren Sudworth