Feedback on SwiftUI responsiveness

BforBank
Bforbank Tech
Published in
15 min readMar 4, 2024

Written by Anthony Giunta

In the ever-changing world of iOS development, the launch of SwiftUI by Apple in 2019 has been a revolution. SwiftUI is a UI toolkik with a event-driven philosophy and declarative approach to user interface creation.

SwiftUI has simplified the way we design and develop our app, it’s provides views, controls, and layout structures for our user interface and event handlers for delivering taps, gestures, and other types of input of our app.

This framework is available across all Apple platforms, including iOS, macOS, watchOS, and tvOS. In the future SwiftUI will be use more and more and will eventualy replace UIKit the current major UI toolkik used to create Apple application.

SwiftUI and UIKit comparison

At BforBank we already had a application develop with the UIKit framewrok, so the question was to decide if we wanted to use SwiftUI for our next application. After having compared the two frameworks, here some strengh and weaknesses of each solution:

Finally inside BforBank we’ve decided to developed a brand new application using mainly the SwiftUI framework.

For us it’s was a good opportunity to dive in the future of the iOS app developement even if swiftUI was launch in 2019 so we took the risk of starting with a technology that was still in its development stages. So this why we decided to use it for our new application and we think that the time has shown that we were right to go with this solution.

Importance of using the right framework

When we are developing an app’s user interface, it’s essential to known the strengh and weaknesses of the framework we choose. Having this expertise of the framework will make our app day to day usage feel smooth and responsive. Otherwise if we choose a framework without being sure that it will reponds well to our needs in terms of performance and design, this will result in a bad experience for our users.

We choose to keep the same architecture as our old application. So we had to adapt the protocol driven approach of UIKit use in our old application to this new framework. At the heart of this evolution the main question we had to answer was how to ensure that we use properly and at it fullest the responsiveness that SwiftUI offer.

In this article, I will share the BforBank team experience we had using the responsiveness mechanisms of SwiftUI, highlighting the benefits we find, the best practices we use, pitfalls to avoid and presenting some few challenges we’re still working through.

In this exploration, we’ll discover how the @State, @Binding, @ObservedObject, @EnvironmentObject wrappers have redefined the way we manage state and data in our new application. We’ll detail how we integrated SwiftUI responsiveness mechanism into our architecture, explain the results of this effort. In a second time will talk about the Async/Await mecanism offer by Swift 5.5 and some more complex examples where we add to use the Combine Framework.

I invite you to dive into this feedback to understand how we fully exploited the potential of responsivenessin SwiftUI, to create a fluid and responsive application that meets the needs of our users. After reading I hope you’ll understand why SwiftUI has met our needs for developing an responsive user interface.

Part 1: How we use the new wrappers of SwiftUI

In the first section I will details the useful wrappers that we use to have responsive views on our application, explaining their use cases and providing concrete examples.

Part 1.1: Communication between views or views and subviews (@State and @Binding)

SwiftUI offers a variety of wrappers for reactivity, such as @State and @Binding. These two wrappers are the basic ones used to update a unique view or a hierarchy of views.

  1. @State : The Responsiveness Foundation

The @State wrapper is the cornerstone of responsiveness in SwiftUI. It is used to create observable properties within a view. Whenever a property annotated with @State changes, SwiftUI automatically detects the modification and updates the associated view. For example, we can use @State to manage the state of an enabled/disabled button or store the value of a text field.

Note that Apple recommend to declare @State properties as private to prevent setting it in a memberwise initializer that can conflict with the storage management.

Here is an exemple, to disabled an button after an button press:

struct ContentView: View {
@State private var isButtonEnabled = false
var body: some View {
Button("Click-here") {
isButtonEnabled.toggle()
}
.disabled(!isButtonEnabled)
}
}

If you pass a @State property to a subview, it will be updates any time the value changes in the parent view, but the subview can’t modify the value. To do this we need to use the next wrapper.

  1. @Binding : Bidirectional link between views

The @Binding wrapper is essential for sharing data between parent and child views. It allows a child view to modify the value of a property in a parent view or the other way around.

To use @Binding, you need to pass a binding from the parent view to the child view as an argument. The child view then uses @Binding to create a bidirectional binding. It’s can access the binding state’s projectedValue by prefixing the property name with a dollar sign.

In the parent view we declare a @State property:

@State private var isToggleOn = false
var body: some View {
ChildView(isToggleOn: $isToggleOn)
}

And in the child we declare a @Binding property:

@Binding var isToggleOn: Bool

When the binding of isToggleOn will change on the child view this will update all the view hierarchy child(s) andparent(s) view(s).

Note that we have to keep a well-organized view hierarchy, because we need to remember that each tome @Bindingproperty is modify it’s will affect all the childs and parents views.

Part 1.2: Communication between the view and the viewModel (@ObservedObject et @Published)

In our applciation we are using an MVVM architecture, in this architecture the viewModel is responsible of the state of the view and how it’s displayed.

So we had to find a way to have the view observing the viewModel. In a first iteration of communication between the view and the viewModel, we used the @ObservedObject wrapper with @Published.

The @Published wrapper is mainly used in the context of observable objects, such as ObservableObjects and data models. When a property is annotated with @Published in an observable class, each modification to this property triggers an event publication. This publication can be observed by other views, enabling the user interface to be updated in line with data modifications.

Why use this wrapper instead of @State, this kind of wrapper if for simple types properties. Storing large amounts of data in @State variables will reduce the performance of our app because SwiftUI will recreates your view each time the state changes.

For Observable Objects, we proceed as follows:

class UserData: ObservableObject {
@Published var username: String = "JohnDoe"
}

Here’s an example of how to use @ObservedObject in an MVVM (Model-View-Model) architecture in SwiftUI :

Let’s suppose we have a beneficiary list application and want to use the MVVM architecture to manage it. Here’s how it could be implemented:

  1. A model representing a beneficiary (Model) :
struct Beneficiary: Identifiable {
let id = UUID()
var name: String
var isFavorite: Bool
}
  1. A ViewModel representing an list of beneficiaries (ViewModel) :

So we create a ViewModel inhereting from ObservableObject containing the list of beneficiaries that manages our view’s data and logic. It uses @Published to announce data changes:

class BeneficiariesViewModel: ObservableObject {
@Published var beneficiaries: [Beneficiary] = []
    func addBeneficiary(name: String) {
let newBeneficiary = Beneficiary(name: title, isFavorite: false)
beneficiaries.append(newBeneficiary)
}
func addBeneficiaryAsFavorite(for beneficiary: Beneficiary) {
if let index = tbeneficiaries.firstIndex(where: { $0.id == beneficiary.id }) {
beneficiaries[index].isFavorite.toggle()
}
}
}

Note that each viewModel have to conform from the ObservableObject protocol. If for example each viewModel inherit from a BaseViewModel class taht conform to ObservableObject this will create performance issue, expecialy in iOS 14.

  1. The interface displaying the list of beneficiaries (View) :

Now, in our view, we use @ObservedObject to monitor our ViewModel and react to data changes, for exemple the update of the beneficiaries list:

struct BeneficiariesView: View {
@ObservedObject private var viewModel: BeneficiariesViewModel
@State private var newBeneficiaryName: String = ""
    var body: some View {
VStack {
TextField("New Beneficiary", text: $newBeneficiaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Button("Add new beneficiary") {
viewModel.addBeneficiary(name: newBeneficiaryName)
newBeneficiaryName = ""
}
.padding()
List(viewModel.beneficiaries) { beneficiary in
HStack {
Text(beneficiary.name)
Spacer()
Image(systemName: beneficiary.isFavorite ? "checkmark.square" : "square")
.onTapGesture {
viewModel.addBeneficiaryAsFavorite(for: beneficiary)
}
}
}
}
}
}

So, thanks to @ObservedObject, our BeneficiariesView view reacts automatically to data modifications in our BeneficiariesViewModel ViewModel. When new beneficiary is added as favorite, the user interface updates in real time, providing a responsive and fluid user experience.

This MVVM architecture with @ObservedObject simplifies application state management, separation of concerns and enables better scalability of your SwiftUI code.

Note that we need to be careful to only declare as @Published the properties that we want to modify the view, and to update the properties associated wrappers when we make modifcations to the viewModel logic.

Partie 1.2: Optimizing State Management with StateObject in SwiftUI

The use of StateObject in SwiftUI is a breakthrough that improves state management in our applications. It’s an alternative to ObservedObject that brings greater clarity and efficiency to our views. In this section, we’ll explore how StateObject simplifies application state management, while providing a concrete example to illustrate its use.

The StateObject wrapper works in the same way as ObservedObject. But when we use state object we have an single source of truth for a reference type that we store in a view hierarchy. We could modify our previous code example to use the StateObject property wrapper instead, and nothing seems to change:

struct BeneficiariesView: View {
@StateObject private var viewModel = BeneficiariesViewModel()
@State private var newBeneficiaryName: String = ""
    var body: some View {
// The rest of the view don't change
}
}

When we use @StateObject, the ViewModel is automatically created and maintained for the lifetime of the view. In this way, data modifications are reflected in real time, and the view can interact with the ViewModel transparently. This means, for example, that when a form is entered, the fields entered are retained as long as the view is in the navigation stack.

In the application, we also use coordinators to simplify navigation and flow management. We declare the coordinator in the view as follows:

struct BeneficiariesView: View {
@ObservedObject var coordinator = BeneficiariesCoordinator()
@StateObject var viewModel = BeneficiariesViewModel()
@State var newBeneficiaryName: String = ""
    var body: some View {
// The rest of the view don't change
}
}

Part 2: Communicate reactive data across our entire application

One of the main challenges we faced in designing the application was to find the best way of communicating data that could be used in several views of the application.

For exemple we whant to have a class that store the settings preferences selected by the user, dark mode, language, etc.. If the user change one of this information we have to update all the views across the application responsively.

Part 2.1: Using StateObject class as Singleton

At the begening we try to use an singleton accessible anywhere on the application. This was a by idea because singleton is not a good way to store information inside the application because:

  • It’ introduces a global state, which can make it harder to track and reason about the behavior of the system
  • It’s can lead to tight coupling between classes
  • Every class should have a single task to do. In case of Singleton class, it will have two responsibility one to create an instance and other to do the actual task.
  • It’s always returns its own instance and is never open for extension.

So we whant to use the native object given by Apple to fit our needs.

Part 2.2: Using @EnvirementObject or @Envirement The Key Element of Environment Management in SwiftUI : @EnvironmentObject

On the world of SwiftUI, environment management plays an essential role in sharing data between different views in an elegant way. The @EnvironmentObject wrapper is a powerful tool for achieving this goal, but it is often confused with @Environment. In this article, we will clear up this confusion by explaining how to use @EnvironmentObject and illustrating the difference between the two with concrete examples.

The use of @EnvironmentObject

@EnvironmentObject allows you to share an observed object between the different views of your SwiftUI hierarchy. It is generally used to store data that needs to be globally accessible throughout the application, in our application we use this wrapper to store for example feature flags values. When a feature flag is update in the debug menu for example we want to update the views link to this flag. In this section I will use a feature flag call showPendingBeneficiariesList as example. To use it, follow these steps:

  1. Create your data model or ViewModel as an observable object conforming to ObservableObject. For example, here’s a data model to store user preferences:
class AppContext: ObservableObject {
@Published var showPendingBeneficiariesList: Bool = false
}
  1. In your application’s main (root) view, we need to initialize the observed object (AppContext) and add it to the environment with .environmentObject modifier:
@main
struct MyApp: App {
let appContext = AppContext()
    var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appContext)
}
}
}
  1. In any child view of your hierarchy, you can access the shared object using @EnvironmentObject. For example, in a view that needs to display the color of the theme selected by the user :
struct BeneficiariesView: View {
@EnvironmentObject var appContext: AppContext
    var body: some View {
listOfActivatedBeneficiaries

if appContext.showPendingBeneficiariesList {
listOfPendingBeneficiaries
}
}
}

This enables all application views to access user preferences from any location, greatly simplifying the management of shared data.

The difference between the @EnvironmentObject and @Environment

It’s important to note that @EnvironmentObject and @Environment are two different concepts. @Environment is used to access environment values provided by SwiftUI, such as the app color schme, font size, etc. These values are managed by the system and are not intended to store custom data. But we can also add our custom keys to @Environment but not that they will read-only so we will need to use a @Binding is hack to be able to mutate the value of the context. But as one major flaw it’s that it can create crash, if a subview try to a @EnvironmentObject that was not injected by the parent view.

In contrast, @EnvironmentObject is specifically designed to store and share custom data between our application’s views. This makes it a fiting choice for user data, global configurations and other information shared within your SwiftUI application.

By using @EnvironmentObject, you can improve modularity and code reuse, while simplifying the management of shared data within your application. Thanks to this clear distinction between @EnvironmentObject and @Environment, you’ll be able to create responsive, high-performance SwiftUI applications, while efficiently organizing the shared data in your application.

Partie 2.3: Use of the Combine Framework for advanced use case

In our projet we also the Combine Framework for some specific case. For example we have a mecanism of “pagination” to display some of the biggest list of transactions or payments.

The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. We can combine the output of multiple publishers and coordinate their interaction.

The idea is that we want to only display a subset of the entire transactions list to our user. If the user scoll at the bottom of the list then we will load and display the next subset of transactions. The detect that the user scroll at the bottom of the list where listening if the last transaction cell as appear on the screen.

To achieve this behavior we have implemented a @Published (Publisher) property that keep the last appeared transaction Id shown to the user. When this value change we are using Combine fucntions to test if we need to call our backend for frontent to load a subset of cells.

On the example below we are filtering nil values, removing duplicates, mapping every time the index of an lastAppearedTransactionId is superior of the size of the list minus the threshold. After that we have an array of boolean and if one if true then we call our backend to load and display more cells.

$lastAppearedTransactionId
.filter { $0 != nil }
.removeDuplicates()
.map { [weak self] (lastTransactionId) -> Bool in
guard let transactionId = lastTransactionId,
let defferedPaymentsEnd = self?.didReachDefferedPaymentsEnd,
let count = self?.defferedPayments.count,
let threshold = self?.loadNextPageThreshold,
let index = self?.defferedPayments.firstIndex(where: { $0.id == transactionId }) else { return false }
return index >= (count - threshold) && !defferedPaymentsEnd
}
.removeDuplicates()
.filter { $0 == true }
.sink { [weak self] _ in
Task { [weak self] in
await self?.loadDefferedPayments()
}
}.store(in: &subscriptions)

By using Combine, we make your code easier to read and maintain, by centralizing your event-processing code and eliminating troublesome techniques like nested closures.

Note that we can attach to an view the onReceive or onChange functions to listen to a publisher and have perform action callback.

func onReceive<P>(
_ publisher: P,
perform action: @escaping (P.Output) -> Void
) -> some View where P : Publisher, P.Failure == Never
func onChange<V>(
of value: V,
perform action: @escaping (V) -> Void
) -> some View where V : Equatable

But overusing this functions can cause code harder to read and maintain. Because for onReceive will be call even if the publisher didn’t change (at initialisation) and otherwise onChange will not be call the newValue of the publisher is equal to the last.

To resolve this pitfall we decided to take advantage of the new structured concurrency pattern that arrived in Swift 5.5, Async/Await that we will details on the next and last section.

Part 3: Update view with pending asynchronous method

Part 3.1: Evolution towards the use of Async/Await for endpoints call

Simplified asynchronous management with SwiftUI Tasks

Tasks, introduced in Swift 5.5, have revolutionized the management of asynchronous operations in SwiftUI. In this section we’ll explore how these new features make managing asynchronous tasks simpler and more efficient. To do this, we’ll look at a concrete example of how Tasks are used in SwiftUI.

Async and Await is now the New Standard, with Swift 5.5, we can now annotate our functions with async to indicate that they are asynchronous. In addition, we use the await keyword to wait for the end of an asynchronous operation. This makes our code more readable and closer to everyday English. For example, suppose we need to make a network request for JSON data. Here’s how it could be done with Tasks in SwiftUI :

func fetchUserData() async throws -> User {
let url = URL(string: "<https://api.example.com/users>")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}

In this example, fetchUserData is marked as an asynchronous function with async. When we call await URLSession.shared.data(from: url), our code waits for the network request to complete before continuing. This greatly simplifies the management of asynchronous operations, eliminating the need for nested completion handlers and making the code more readable.

This also simplified error handling, in the application. SwiftUI Tasks also simplify error handling in asynchronous operations. Thanks to error capture with try in asynchronous functions, we can handle errors elegantly. For example:

@State userName = ""
@State userIsFavorite = false
@State showErrorView = false
.
.
.
.onAppear {
Task {
do {
let user = try await viewModel.fetchUserData()
userName = user.name
userIsFavorite = user.IsFavorite
} catch {
// Handle errors
print("Error on date fetching : \(error)")
showErrorView =true
}
}
}

In this example, we use a do-catch block to catch any errors when calling fetchUserData. This allows us to handle errors centrally, improving code maintainability.

We decide to use the async/await because it’s have a lot of advantage. One it’s get rid of completion closures that have multiple problems, the syntax @escaping (String) -> Void was hard to read and it’s cloud be call ultiple times we the wrong implementation.

On this example we can see that we are calling a viewModel method to fetch user info inside the onAppear modifier and update after the try await some @State to update the view.

The user object is not store in the viewModel if it’s only necessary for the view. In one a the first iteration of the project we were delcaring a @Published var user: User inside the viewModel, fetch the user and update the view using on onReceive(viewModel.$user, perform: viewStatesUpdateCalback). This was not good because two many comunication between the view and the viewModel and it’s was difficult to read and maintain. So now we can see that the Async/Await mecanism helps us to make our code easier to understand.

Note that we are now using the .task modifer over the .onAppear to improve the readabilty and performance of our code.

In summary, Tasks in SwiftUI revolutionize the management of asynchronous operations, simplifying code, improving readability and facilitating error handling. Their introduction marks a major step forward in iOS development, paving the way for more powerful and responsive applications.

Conclusion: SwiftUI responsiveness is in perpetual evolution

By exploring the concepts of responsiveness in SwiftUI, we’ve seen how these tools have already revolutionized the creation of responsive user interfaces. However, the future holds many more innovations, for exemple new @Observable macro that will replace the . This new feature will further simplify the code readability, management and complexity.

As SwiftUI evolves, responsiveness remains at the heart of its design. Whether we’re talking about mobile applications or solutions for other Apple platforms, SwiftUI responsiveness will continue to play a central role at BforBank in improving the user experience.

Even if we had some challenges, we can say now that overall we having a good overview of SwiftUI tools to insure to have a smooth and responsive application UI. We are carefully adapting our achictecture to SwiftUI event driven philosophy. By taking advantage of the delcarative way of building views and all the responsive tools we have improved our iOS team velocity while maintaining a high technical quality bar.

We’re looking forward to see how we can continue to use the full potential that the next version of SwiftUI will offer in term of responsiveness for our app.

Ressources:

https://developer.apple.com/tutorials/swiftui

https://developer.apple.com/documentation/swiftui/model-data

https://developer.apple.com/documentation/swiftui/stateobject

https://developer.apple.com/documentation/swiftui/observedobject

https://developer.apple.com/documentation/swiftui/environment-values

https://developer.apple.com/documentation/swift/task

https://developer.apple.com/documentation/swiftui/system-events

https://developer.apple.com/documentation/observation/observable()

--

--