I am new to Combine and am struggling with a few concepts around communication. I come from a web background and before that was UIKit, so a different landscape to SwiftUI.
I am very keen to use MVVM to keep business logic away from the View layer. This means that any view that is not a reusable component, has a ViewModel to handle API requests, logic, error handling etc.
The problem I have run into is what is the best way to pass events to the View when something happens in the ViewModel. I understand that a view should be a reflection of state, but for things that are event driven, it requires a bunch of variables that I think is messy and so keen to get other approaches.
The example below is a ForgotPasswordView. It is presented as a sheet, and when a successful reset occurs, it should close + show a success toast. In the case that it fails, there should be an error toast shown (for context the global toast coordinator is managed via an #Environment variable injected at the root of the app).
Below is a limited example
View
struct ForgotPasswordView: View {
/// Environment variable to dismiss the modal
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
/// The forgot password view model
#StateObject private var viewModel: ForgotPasswordViewModel = ForgotPasswordViewModel()
var body: some View {
NavigationView {
GeometryReader { geo in
ScrollView {
// Field contents + button that calls method
// in ViewModel to execute the network method. See `sink` method for response
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
}
}
/// Close the presented sheet
private func closeSheet() -> Void {
self.presentationMode.wrappedValue.dismiss()
}
}
ViewModel
class ForgotPasswordViewModel: ObservableObject {
/// The value of the username / email address field
#Published var username: String = ""
/// Reference to the reset password api
private var passwordApi = Api<Response<Success>>()
/// Reference to the password api for cancelling
private var apiCancellable: AnyCancellable?
init() {
self.apiCancellable = self.passwordApi.$status
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let result = result else { return }
switch result {
case .inProgress:
// Handle in progress
case let .success(response):
// Handle success
case let .failed(error):
// Handle failure
}
}
}
}
The above ViewModel has all the logic, and the View simply reflects the data and calls methods. Everything good so far.
Now, in order to handle the success and failed states of the server response, and get that information to the UI, is where I run into issues. I can think of a few ways, but I either dislike, or seem not possible.
With variables
Create individual #Published variables for each state e.g.
#Published var networkError: String? = nil
then set them is the different states
case let .failed(error):
// Handle failure
self.networkError = error.description
}
In the View I can then subscribe to this via onRecieve and handle the response
.onReceive(self.viewModel.$networkError, perform: { error in
if error {
// Call `closeSheet` and display toast
}
})
This works, but this is a single example and would require me to create a #Published variable for every state. Also, these variables would have to be cleaned up too (setting them back to nil.
This could be made more graceful by using an enum with associated values, so that there is only a single listener + variable that needs to be used. The enum doesn't however deal with the fact that the variable has to be cleaned up.
With PassthroughSubject
Building on this, I went looking at PassthroughSubject thinking that if I create a single #Publisher like
#Publisher var events: PassthoughSubject = PassthroughSubject<Event, Never>
And publish events like this:
.sink { [weak self] result in
guard let result = result else { return }
switch result {
case let .success(response):
// Do any processing of success response / call any methods
self.events.send(.passwordReset)
case let .failed(error):
// Do any processing of error response / call any methods
self.events.send(.apiError(error)
}
}
Then I could listen to it like this
.onReceive(self.viewModel.$events, perform: { event in
switch event {
case .passwordReset:
// close sheet and display success toast
case let .apiError(error):
// show error toast
})
This is better that the variable, as the events are sent with .send and so the events variable doesn't need cleaning up.
Unfortunately though, it doesn't seem that you can use onRecieve with a PassthroughSubject. If i made it a Published variable but with the same concept, then I would run into the having to clean it up again issue that the first solution had.
With everything in the view
The last scenario, that I have been trying to avoid is to handle everything in the View
struct ForgotPasswordView: View {
/// Environment variable to dismiss the modal
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
/// Reference to the reset password api
#StateObject private var passwordApi = Api<Response<Success>>()
var body: some View {
NavigationView {
GeometryReader { geo in
ScrollView {
// Field contents + button that all are bound/call
// in the view.
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
.onReceive(self.passwordApi.$status, perform: { status in
guard let result = result else { return }
switch result {
case .inProgress:
// Handle in progress
case let .success(response):
// Handle success via closing dialog + showing toast
case let .failed(error):
// Handle failure via showing toast
}
})
}
}
}
The above is a simple example, but if there is more complex processing or data manipulating that needs to happen, I don't want that to be in the View as it is messy. Additionally, in this case the success / failure event perfectly matches the events that are needed to be handled in the UI, not every view would fall into that category though, so even more processing could need to be done.
I have run into this conundrum for nearly every view that has a model, if something happens in the ViewModel that is a basic event, how should it be communicated to the View. I feel like there should be a nicer way to do this, which also leads me to think I am doing it wrong.
That was a large wall of text, but I am keen to ensure that the architecture of the app in maintainable, easily testable, and views focus on displaying data and calling mutations (but not at the expense of there being lots of boilerplate variables in the ViewModel)
Thanks
You can have the result of your reset password request delivered to a #Published property of your view model. SwiftUI will automatically update the associated view when the state changes.
In the following I wrote a password reset form similar to yours, with a view and an underlying view model. The view model has a state with four possible values from a nested State enum:
idle as initial state or after the username has been changed.
loading when the reset request is being performed.
success and failure when the result of the reset request is known.
I simulated the password reset request with a simple delayed publisher which fails when an invalid username is detected (for simplicity only usernames containing # are considered valid). The publisher result is directly assigned to the published state property using .assign(to: &$state), a very convenient way to connect publishers together:
import Combine
import Foundation
final class ForgotPasswordViewModel: ObservableObject {
enum State {
case idle
case loading
case success
case failed(message: String)
}
var username: String = "" {
didSet {
state = .idle
}
}
#Published private(set) var state: State = .idle
// Simulate some network request to reset the user password
private static func resetPassword(for username: String) -> AnyPublisher<State, Never> {
return CurrentValueSubject(username)
.delay(for: .seconds(.random(in: 1...2)), scheduler: DispatchQueue.main)
.map { username in
return username.contains("#") ? State.success : State.failed(message: "The username does not exist")
}
.eraseToAnyPublisher()
}
func resetPassword() {
state = .loading
Self.resetPassword(for: username)
.receive(on: DispatchQueue.main)
.assign(to: &$state)
}
}
The view itself instantiates and stores the view model as #StateObject. The user can enter their name and trigger ask for a password reset. Each time the view model state changes a body update is automatically triggered, which allows the view to adjust appropriately:
import SwiftUI
struct ForgotPasswordView: View {
#StateObject private var model = ForgotPasswordViewModel()
private var statusMessage: String? {
switch model.state {
case .idle:
return nil
case .loading:
return "Submitting"
case .success:
return "The password has been reset"
case let .failed(message: message):
return "Error: \(message)"
}
}
var body: some View {
VStack(spacing: 40) {
Text("Password reset")
.font(.title)
TextField("Username", text: $model.username)
Button(action: resetPassword) {
Text("Reset password")
}
if let statusMessage = statusMessage {
Text(statusMessage)
}
Spacer()
}
.padding()
}
private func resetPassword() {
model.resetPassword()
}
}
The code above can easily be tested in an Xcode project.
Related
I need to implement a mechanism just like react private and public route on swiftUI. Basically I have tens of views and some of these views requires authentication based on user logged in status. So far I have tried to hold current screen in an Environment Object as show in following class
enum Routes {
case screenA,
screenB,
screenC,
screenD,
screenE,
screenF,
screenG,
loginScreen
var isAuthRequired: Bool {
if case . screenA = self {
return true
} else if case . screenD = self {
return true
} else {
return false
}
}
}
class AuthenticatedRoute: ObservableObject {
#Published var currentRoute: Routes
init(){
self.currentRoute = . screenA
}
}
And on my main screen I check every time the current screen change whether user loggedin and current page require authentication.
struct MainView: View {
#StateObject var authenticatedRoute = AuthenticatedRoute()
#EnvironmentObject var userAuth: UserAuth
var body: some View {
mainView()
.environmentObject(authenticatedRoute)
}
#ViewBuilder
func mainView() -> some View {
if (self.authenticatedRoute.currentRoute.isAuthRequired && !userAuth.isLoggedIn) {
LoginView()
}
else {
DefaultTabView()
}
}
}
And this is an example of how I keep changing this environment variable. I change the environment object onAppear event method of view.
struct ScreenA: View {
#EnvironmentObject var authenticatedRoute: AuthenticatedRoute
var body: some View {
NavigationView {
someContent()
}.onAppear {
authenticatedRoute.currentRoute = .screenA
}
}
}
While this approach works for most cases for some reason it behave strange when a screen is in tab navigation. Also I do not feel comfortable with this solution, that I need to change screen name manually on every single page, and checking authentication status in main view. I think it would be better somehow if I can write a kind of interceptor before every page change and check if desired destination requires authentication and if user is authenticated but I could not find a way manage this. I'm relatively new to iOS development and had experience with react native but this should not be so hard to implement in my opinion since this is a requirement for most applications.
So basically I need to implement a private and public router in swiftUI or intercept every page change so I should not modify environment variable on each pages manually and should not check conditions in MainView inside a function.
I can propose another approach, that does not require the class AuthenticatedRoute, I hope it's what you are looking for. The process is:
In the class UserAuth, create a static shared instance, that can be called anywhere in your code and ensures you are always using the same instance.
Create a modifier extending View, that reads the status in UserAuth.shared and shows the necessary view according to whether the authentication is required (LoginView() if the user is not authenticated).
Use the modifier at the outermost container (VStack, NavigationView, whatever) of any view that requires the user to be authenticated.
The example below shows how this can work, if you want to run it:
1. Static UserAuth instance
class UserAuth: ObservableObject {
static let shared = UserAuth() // This is to assure that you refer to the same instance all over the code
#Published private(set) var isLoggedIn = false // Use the variable you already have
func logInOrOff() { // Implement each func as needed
isLoggedIn.toggle()
}
}
2. Create the View modifier
extension View {
#ViewBuilder
func requiresAuthentication() -> some View {
if UserAuth.shared.isLoggedIn { // "shared" is the same instance used by the views
self
} else {
LoginView()
}
}
}
3. Apply the modifier at the bottom of the view that requires authentication
struct Example: View {
#StateObject private var userAuth = UserAuth.shared // Or #EnvironmentObject, as you wish
var body: some View {
VStack {
Text(userAuth.isLoggedIn ? "Now we're good" : "You must log in")
.padding()
Button {
userAuth.logInOrOff()
} label: {
Text("Logoff")
}
}
.requiresAuthentication() // This is what makes your view safe
}
}
struct LoginView: View {
var body: some View {
VStack {
Text("Authentication is required")
.padding()
Button {
UserAuth.shared.logInOrOff()
} label: {
Text("Log in")
}
}
}
}
I'm loading data from an API, and expecting my app to show the data once it's loaded.
In my View Model file, here's the code:
It calls a WeatherService to get the data, and populates the weather property. Weather is a struct in this case.
class WeatherViewModel: ObservableObject {
let webService = WeatherService.shared
#Published var weather:Weather?
init() {
}
func getWeather() {
webService.getWeather { weather in
if let weather = weather {
self.weather = weather
}
}
}
}
In my SwiftUI view, here's the code:
I instantiate an instance of the View Model as an ObservedObject
In the inAppear, I call the method in the view model to get the data
The first time the screen launches (using a tab bar), I see "Loading weather..." and it never goes away
If I navigate to a different tab and back, I see the weather. I can't tell if this is data from the old API call, or from the new one.
struct WeatherView: View {
#ObservedObject var weatherViewModel = WeatherViewModel()
#State var areDetailsHidden = true
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if(weatherViewModel.weather == nil) {
Text("Loading weather...")
} else {
Text("Display the weather here")
}
}
.onAppear{
self.weatherViewModel.getWeather()
}
}
}
The weird thing is, if I remove the getWeather() from the onAppear and add it to the init() of the View Model, it works (although for some reason getWeather() gets called twice...). However, I want the weather info to be refreshed every time the screen is loaded.
This is caused by the:
#ObservedObject var weatherViewModel = WeatherViewModel()
being owned by the WeatherView itself.
So what happens is the weather view model changes which forces a re-render of the view which creates a new copy of the weather view model, which changes forces a re-render...
So you end up with an endless loop.
To fix it you need to move the weather view model out of the view itself so either use an #Binding and pass it in or an #EnvironmentObject and access it that way.
I have an ObservableObject which is supposed to hold my application state:
final class Store: ObservableObject {
#Published var fetchInterval = 30
}
now, that object is being in injected at the root of my hierarchy and then at some component down the tree I'm trying to access it and bind it to a TextField, namely:
struct ConfigurationView: View {
#EnvironmnetObject var store: Store
var body: some View {
TextField("Fetch interval", $store.fetchInterval, formatter: NumberFormatter())
Text("\(store.fetchInterval)"
}
}
Even though the variable is binded (with $), the property is not being updated, the initial value is displayed correctly but when I change it, the textfield changes but the binding is not propagated
Related to the first question, is, how would I receive an event once the value is changed, I tried the following snippet, but nothing is getting fired (I assume because the textfield is not correctly binded...
$fetchInterval
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.sink { interval in
print("sink from my code \(interval)")
}
Any help is much appreciated.
Edit: I just discovered that for text variables, the binding works fine out of the box, ex:
// on store
#Published var testString = "ropo"
// on component
TextField("Ropo", text: $store.testString)
Text("\(store.testString)")
it is only on the int field that it does not update the variable correctly
Edit 2:
Ok I have just discovered that only changing the field is not enough, one has to press Enter for the change to propagate, which is not what I want, I want the changes to propagate every time the field is changed...
For anyone that is interested, this is te solution I ended up with:
TextField("Seconds", text: Binding(
get: { String(self.store.fetchInterval) },
set: { self.store.fetchInterval = Int($0.filter { "0123456789".contains($0) }) ?? self.store.fetchInterval }
))
There is a small delay when a non-valid character is added, but it is the cleanest solution that does not allow for invalid characters without having to reset the state of the TextField.
It also immediately commits changes without having to wait for user to press enter or without having to wait for the field to blur.
Do it like this and you don't even have to press enter. This would work with EnvironmentObject too, if you put Store() in SceneDelegate:
struct ContentView: View {
#ObservedObject var store = Store()
var body: some View {
VStack {
TextField("Fetch interval", text: $store.fetchInterval)
Text("\(store.fetchInterval)")
}
} }
Concerning your 2nd question: In SwiftUI a view gets always updated automatically if a variable in it changes.
how about a simple solution that works well on macos as well, like this:
import SwiftUI
final class Store: ObservableObject {
#Published var fetchInterval: Int = 30
}
struct ContentView: View {
#ObservedObject var store = Store()
var body: some View {
VStack{
TextField("Fetch interval", text: Binding<String>(
get: { String(format: "%d", self.store.fetchInterval) },
set: {
if let value = NumberFormatter().number(from: $0) {
self.store.fetchInterval = value.intValue
}}))
Text("\(store.fetchInterval)").padding()
}
}
}
This one I've been researching for a few days, scouring the Swift & SwiftUI docs, SO, forums, etc. and can't seem to find an answer.
Here is the problem;
I have a SwiftUI custom View that does some state determination on a custom API request class to a remote resource. The View handles showing loading states and failure states, along with its body contents being passed through via ViewBuilder so that if the state from the API is successful and the resource data is loaded, it will show the contents of the page.
The issue is, the ViewBuilder contents does not re-render when the subclassed ObservedObject updates. The Object updates in reaction to the UI (when buttons are pressed, etc.) but the UI never re-renders/updates to reflect the change within the subclassed ObservedObject, for example the ForEach behind an array within the subclassed ObservedObject does not refresh when the array contents change. If I move it out of the custom View, the ForEach works as intended.
I can confirm the code compiles and runs. Observers and debugPrint()'s throughout show that the ApiObject is updating state correctly and the View reflects the ApiState change absolutely fine. It's just the Content of the ViewBuilder. In which I assume is because the ViewBuilder will only ever be called once.
EDIT: The above paragraph should have been the hint, the ApiState updates correctly, but after putting extensive logging into the application, the UI was not listening to the publishing of the subclassed ObservedObject. The properties were changing and the state was too, but the UI wasn't being reactive to it.
Also, the next sentence turned out to be false, I tested again in a VStack and the component still didn't re-render, meaning I was looking in the wrong place!
If this is the case, how does VStack and other such elements get around this?
Or is it because my ApiObjectView is being re-rendered on the state change, in which causes the child view to 'reset'? Although in this circumstance I'd expect it to then take on the new data and work as expected anyway, its just never re-rendering.
The problematic code is in the CustomDataList.swift and ApiObjectView.swift below. I've left comments to point in the right direction.
Here is the example code;
// ApiState.swift
// Stores the API state for where the request and data parse is currently at.
// This drives the ApiObjectView state UI.
import Foundation
enum ApiState: String
{
case isIdle
case isFetchingData
case hasFailedToFetchData
case isLoadingData
case hasFailedToLoadData
case hasUsableData
}
// ApiObject.swift
// A base class that the Controllers for the app extend from.
// These classes can make data requests to the remote resource API over the
// network to feed their internal data stores.
class ApiObject: ObservableObject
{
#Published var apiState: ApiState = .isIdle
let networkRequest: NetworkRequest = NetworkRequest(baseUrl: "https://api.example.com/api")
public func apiGetJson<T: Codable>(to: String, decodeAs: T.Type, onDecode: #escaping (_ unwrappedJson: T) -> Void) -> Void
{
self.apiState = .isFetchingData
self.networkRequest.send(
to: to,
onComplete: {
self.apiState = .isLoadingData
let json = self.networkRequest.decodeJsonFromResponse(decodeAs: decodeAs)
guard let unwrappedJson = json else {
self.apiState = .hasFailedToLoadData
return
}
onDecode(unwrappedJson)
self.apiState = .hasUsableData
},
onFail: {
self.apiState = .hasFailedToFetchData
}
)
}
}
// DataController.swift
// This is a genericised example of the production code.
// These controllers build, manage and serve their resource data.
// Subclassed from the ApiObject, inheriting ObservableObject
import Foundation
import Combine
class CustomDataController: ApiObject
{
#Published public var customData: [CustomDataStruct] = []
public func fetch() -> Void
{
self.apiGetJson(
to: "custom-data-endpoint ",
decodeAs: [CustomDataStruct].self,
onDecode: { unwrappedJson in
self.customData = unwrappedJson
}
)
}
}
This is the View that has the problem with re-rendering its ForEach on the ObservedObject change to its bound array property.
// CustomDataList.swift
// This is the SwiftUI View that drives the content to the user as a list
// that displays the CustomDataController.customData.
// The ForEach in this View
import SwiftUI
struct CustomDataList: View
{
#ObservedObject var customDataController: CustomDataController = CustomDataController()
var body: some View
{
ApiObjectView(
apiObject: self.customDataController,
onQuit: {}
) {
List
{
Section(header: Text("Custom Data").padding(.top, 40))
{
ForEach(self.customDataController.customData, id: \.self, content: { customData in
// This is the example that doesn't re-render when the
// customDataController updates its data. I have
// verified via printing at watching properties
// that the object is updating and pushing the
// change.
// The ObservableObject updates the array, but this ForEach
// is not run again when the data is changed.
// In the production code, there are buttons in here that
// change the array data held within customDataController.customData.
// When tapped, they update the array and the ForEach, when placed
// in the body directly does reflect the change when
// customDataController.customData updates.
// However, when inside the ApiObjectView, as by this example,
// it does not.
Text(customData.textProperty)
})
}
}
.listStyle(GroupedListStyle())
}
.navigationBarTitle(Text("Learn"))
.onAppear() {
self.customDataController.fetch()
}
}
}
struct CustomDataList_Previews: PreviewProvider
{
static var previews: some View
{
CustomDataList()
}
}
This is the custom View in question that doesn't re-render its Content.
// ApiObjectView
// This is the containing View that is designed to assist in the UI rendering of ApiObjects
// by handling the state automatically and only showing the ViewBuilder contents when
// the state is such that the data is loaded and ready, in a non errornous, ready state.
// The ViewBuilder contents loads fine when the view is rendered or the state changes,
// but the Content is never re-rendered if it changes.
// The state renders fine and is reactive to the object, the apiObjectContent
// however, is not.
import SwiftUI
struct ApiObjectView<Content: View>: View {
#ObservedObject var apiObject: ApiObject
let onQuit: () -> Void
let apiObjectContent: () -> Content
#inlinable public init(apiObject: ApiObject, onQuit: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self.apiObject = apiObject
self.onQuit = onQuit
self.apiObjectContent = content
}
func determineViewBody() -> AnyView
{
switch (self.apiObject.apiState) {
case .isIdle:
return AnyView(
ActivityIndicator(
isAnimating: .constant(true),
style: .large
)
)
case .isFetchingData:
return AnyView(
ActivityIndicator(
isAnimating: .constant(true),
style: .large
)
)
case .isLoadingData:
return AnyView(
ActivityIndicator(
isAnimating: .constant(true),
style: .large
)
)
case .hasFailedToFetchData:
return AnyView(
VStack
{
Text("Failed to load data!")
.padding(.bottom)
QuitButton(action: self.onQuit)
}
)
case .hasFailedToLoadData:
return AnyView(
VStack
{
Text("Failed to load data!")
.padding(.bottom)
QuitButton(action: self.onQuit)
}
)
case .hasUsableData:
return AnyView(
VStack
{
self.apiObjectContent()
}
)
}
}
var body: some View
{
self.determineViewBody()
}
}
struct ApiObjectView_Previews: PreviewProvider {
static var previews: some View {
ApiObjectView(
apiObject: ApiObject(),
onQuit: {
print("I quit.")
}
) {
EmptyView()
}
}
}
Now, all the above code works absolutely fine, if the ApiObjectView isn't used and the contents placed in the View directly.
But, that is horrendous for code reuse and architecture, this way its nice and neat, but doesn't work.
Is there any other way to approach this, e.g. via a ViewModifier or a View extension?
Any help on this would be really appreciated.
As I said, I can't seem to find anyone with this problem or any resource online that can point me in the right direction to solve this problem, or what might be causing it, such as outlined in documentation for ViewBuilder.
EDIT: To throw something interesting in, I've since added a countdown timer to CustomDataList, which updates a label every 1 second. IF the text is updated by that timer object, the view is re-rendered, but ONLY when the text on the label displaying the countdown time is updated.
Figured it out after pulling my hair out for a week, its an undocumented issue with subclassing an ObservableObject, as seen in this SO answer.
This is particularily annoying as Xcode obviously prompts you to remove the class as the parent class provides that inheritence to ObservableObject, so in my mind all was well.
The fix is, within the subclassed class to manually fire the generic state change self.objectWillChange.send() via the willSet listener on the #Published variable in question, or any you require.
In the examples I provided, the base class ApiObject in the question remains the same.
Although, the CustomDataController needs to be modified as follows:
// DataController.swift
// This is a genericised example of the production code.
// These controllers build, manage and serve their resource data.
import Foundation
import Combine
class CustomDataController: ApiObject
{
#Published public var customData: [CustomDataStruct] = [] {
willSet {
// This is the generic state change fire that needs to be added.
self.objectWillChange.send()
}
}
public func fetch() -> Void
{
self.apiGetJson(
to: "custom-data-endpoint ",
decodeAs: [CustomDataStruct].self,
onDecode: { unwrappedJson in
self.customData = unwrappedJson
}
)
}
}
As soon as I added that manual publishing, the issue is resolved.
An important note from the linked answer: Do not redeclare objectWillChange on the subclass, as that will again cause the state not to update properly. E.g. declaring the default
let objectWillChange = PassthroughSubject<Void, Never>()
on the subclass will break the state updating again, this needs to remain on the parent class that extends from ObservableObject directly, either my manual or automatic default definition (typed out, or not and left as inherited declaration).
Although you can still define as many custom PassthroughSubject declarations as you require without issue on the subclass, e.g.
// DataController.swift
// This is a genericised example of the production code.
// These controllers build, manage and serve their resource data.
import Foundation
import Combine
class CustomDataController: ApiObject
{
var customDataWillUpdate = PassthroughSubject<[CustomDataStruct], Never>()
#Published public var customData: [CustomDataStruct] = [] {
willSet {
// Custom state change handler.
self.customDataWillUpdate.send(newValue)
// This is the generic state change fire that needs to be added.
self.objectWillChange.send()
}
}
public func fetch() -> Void
{
self.apiGetJson(
to: "custom-data-endpoint ",
decodeAs: [CustomDataStruct].self,
onDecode: { unwrappedJson in
self.customData = unwrappedJson
}
)
}
}
As long as
The self.objectWillChange.send() remains on the #Published properties you need on the subclass
The default PassthroughSubject declaration is not re-declared on the subclass
It will work and propagate the state change correctly.
When a SwiftUI View binds to an ObservableObject, the view is automatically reloaded when any change occurs within the observed object - regardless of whether the change directly affects the view.
This seems to cause big performance issues for non-trivial apps. See this simple example:
// Our observed model
class User: ObservableObject {
#Published var name = "Bob"
#Published var imageResource = "IMAGE_RESOURCE"
}
// Name view
struct NameView: View {
#EnvironmentObject var user: User
var body: some View {
print("Redrawing name")
return TextField("Name", text: $user.name)
}
}
// Image view - elsewhere in the app
struct ImageView: View {
#EnvironmentObject var user: User
var body: some View {
print("Redrawing image")
return Image(user.imageResource)
}
}
Here we have two unrelated views, residing in different parts of the app. They both observe changes to a shared User supplied by the environment. NameView allows you to edit User's name via a TextField. ImageView displays the user's profile image.
The problem: With each keystroke inside NameView, all views observing this User are forced to reload their entire body content. This includes ImageView, which might involve some expensive operations - like downloading/resizing a large image.
This can easily be proven in the example above, because "Redrawing name" and "Redrawing image" are logged each time you enter a new character in the TextField.
The question: How can we improve our usage of Observable/Environment objects, to avoid unnecessary redrawing of views? Is there a better way to structure our data models?
Edit:
To better illustrate why this can be a problem, suppose ImageView does more than just display a static image. For example, it might:
Asynchronously load an image, trigged by a subview's init or onAppear method
Contain running animations
Support a drag-and-drop interface, requiring local state management
There's plenty more examples, but these are what I've encountered in my current project. In each of these cases, the view's body being recomputed results in discarded state and some expensive operations being cancelled/restarted.
Not to say this is a "bug" in SwiftUI - but if there's a better way to architect our apps, I have yet to see it mentioned by Apple or any tutorials. Most examples seem to favor liberal usage of EnvironmentObject without addressing the side effects.
Why does ImageView need the entire User object?
Answer: it doesn't.
Change it to take only what it needs:
struct ImageView: View {
var imageName: String
var body: some View {
print("Redrawing image")
return Image(imageName)
}
}
struct ContentView: View {
#EnvironmentObject var user: User
var body: some View {
VStack {
NameView()
ImageView(imageName: user.imageResource)
}
}
}
Output as I tap keyboard keys:
Redrawing name
Redrawing image
Redrawing name
Redrawing name
Redrawing name
Redrawing name
A quick solution is using debounce(for:scheduler:options:)
Use this operator when you want to wait for a pause in the delivery of events from the upstream publisher. For example, call debounce on the publisher from a text field to only receive elements when the user pauses or stops typing. When they start typing again, the debounce holds event delivery until the next pause.
I have done this little example quickly to show a way to use it.
// UserViewModel
import Foundation
import Combine
class UserViewModel: ObservableObject {
// input
#Published var temporaryUsername = ""
// output
#Published var username = ""
private var temporaryUsernamePublisher: AnyPublisher<Bool, Never> {
$temporaryUsername
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.eraseToAnyPublisher()
}
init() {
temporaryUsernamePublisher
.receive(on: RunLoop.main)
.assign(to: \.username, on: self)
}
}
// View
import SwiftUI
struct ContentView: View {
#ObservedObject private var userViewModel = UserViewModel()
var body: some View {
TextField("Username", text: $userViewModel.temporaryUsername)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I hope that it helps.