How can I invalidate a SwiftUI View in response to a notification? - ios

I'm new to SwiftUI and trying to start using it in a complex, existing UIKit app. The app has a theming system, and I'm not sure how to get the SwiftUI view to respond to theme change events.
Our theme objects look like
class ThemeService {
static var textColor: UIColor { get }
}
struct ViewTheme: {
private(set) var textColor = { ThemeService.textColor }
}
where the value returned ThemeService.textColor changes when the user changes the app's theme. In the UIKit portions of the app, views observe a "themeChanged" Notification and re-read the value of the textColor property from their theme structs.
I'm not sure how to manage this for SwiftUI. Since ViewTheme isn't an object, I can't use #ObservableObject, but its textColor property also doesn't change when the theme changes; just the value returned by calling it changes.
Is there a way to somehow get SwiftUI to re-render the view hierarchy from an external event, rather than from a change in a value that the view sees? Or should I be approaching this differently?

Your answer works perfectly well, but it requires adoption of ObservableObject. Here is an alternative answer which uses your existing NotificationCenter notifications to update a SwiftUI view.
struct MyView: View {
#State private var textColor: UIColor
var body: some View {
Text("Hello")
.foregroundColor(Color(textColor))
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("themeChanged"))) { _ in
textColor = ThemeService.textColor
}
}
}
This requires a #State variable to hold the theme's current data, but it's correct because a SwiftUI view is really just a snapshot of what the view should currently display. It ideally should not reference data that is arbitrarily changed because it leads to data-sync problems like the question you asked. So in a SwiftUI view, it is problematic to write ThemeService.textColor directly within the body unless you are certain an update will always occur after it the theme gets changed.

I was able to get this working by cheating with the theming system a little and changing the ViewTheme to an object:
class ViewTheme: ObservableObject {
private(set) var textColor = { ThemeService.textColor }
init() {
NotificationCenter.default.addObserver(forName: Notification.Name("themeChanged"),
object: nil, queue: .main) { [weak self] _ in
self?.objectWillChange.send()
}
}
}
Now the view can mark it with #ObservedObject and will be re-generated when the "themeChanged" notification is fired.
This seems to be working great, but I'm not sure if there are non-obvious problems that I'm missing with this solution.

Related

SwiftUI: Update Tab Bar Badge when Core Data item is added

This Problem drives me nuts: I have a small SwiftUi iOS App which works with Core data. All works fine, I can add edit etc. But the app is tab bar based ad I want one tabs badge updated when a record is added to core data.
So I thought I listen to the viewontext.hasChanged bool:
struct MainView: View {
#Environment(\.managedObjectContext) private var viewContext
#State var nrOfItems = 0
let persistanceController : PersistenceController
var body: some View {
TabView{
ItemListView(persistance: PersistenceController.shared)
.tabItem{
Label("Lebensmittel",systemImage: "cart")
}
.badge(nrOfItems)
}
.onChange(of: viewContext.hasChanges, perform: {newValue in
nrOfItems = persistanceController.getRecordsCount()
})
}
}
But the code in onChange is never called ... and I can't find anything about it neither here nor somewhere else. Anyone any hints?
Thanks, Andreas
The hasChanges is not published property, so it does not trigger onChange, try instead to use notification directly, like
.onReceive(NotificationCenter.default.publisher(for: NSManagedObjectContext.didChangeObjectsNotification,
object: viewContext)) { _ in
nrOfItems = persistanceController.getRecordsCount()
}

How to handle onChange of Text event in SwiftUI?

I have a ContentView where I have a variable:
#State private var textToShow = "Some Text"
That I show in:
Text(textToShow)
I have a button where when I click it, it changes textToShow to equal "Changed Text". What is the right way to attach some kind of event that triggers when the Text changes? I am looking for something like a Text(textToShow).onChange(print("Text Changed")).
Note that I do not have any IBAction and I am not using any storyboards.
I would trigger any side-effects as early as possible. That would be the button-action. If you trigger side-effects from side-effects from side-effects it will become hard to track all changes that may occur when you tap the button. I used to implement such a chain of multiple bindings. It was horrible to maintain.
If you still want to observe the Text-View, then you may just observe the state itself and not the Text-View. Side-effects can be trigged by the View‘s onChange-modifier. https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)
Text(textToShow)
.onChange(of: textToShow) { newValue in
print(...)
}
You could also achieve this using an ObservableObject.
First, declare a model class that conforms to the ObservableObject protocol. This will store the text and apply a didSet property observer.
class Model: ObservableObject {
#Published var textValue: String = "" {
didSet {
print("Text changed!")
}
}
}
Then use it in the relevant view.
struct ContentView: View {
// Create observed object
#ObservedObject var model = Model()
var body: some View {
Text($model.textValue)
}
}

Using a design pattern like MVC with SwiftUI

I am trying to implement a design pattern like MVC in order to achieve low coupling between different parts of the code. There are few materials online that I personally didn't find helpful in relation to IOS or swift UI development and MVC pattern.
What I am trying to understand is how should the controller class control or render the UI in Swift UI ?
Following the MVC pattern for example - the View shouldn't know about how the model looks like, so sending an object back from the Data Base to the view in order to visually present it wouldn't be a good Idea..
Say we have the following View and Controller, how should I go about the interaction between the controller and view when sending the data back from the DB in order to visually present it in the view ?
View:
import SwiftUI
import Foundation
struct SwiftUIView: View {
var assignmentController = AssignmentController()
#State var assignmentName : String = ""
#State var notes : String = ""
var body: some View {
NavigationView {
VStack {
Form {
TextField("Assignment Name", text: $assignmentName)
TextField("Notes", text: $notes)
}
Button(action: {
self.assignmentController.retrieveFirstAssignment()
}) {
Text("Get The First Assignment !")
}
}
.navigationBarTitle("First Assignment")
}
}
}
Controller
var assignmentModel = AssignmentModel()
func retrieveFirstAssignment()
{
var firstAssignment : Assignment
firstAssignment=assignmentModel.retrieveFirstAssignment()
}
For now, it does nothing with the object it found.
Model
An object in the model composed of two String fields : "assignmentName" and "notes".
*We assume the assignment Model have a working function that retrieves one task from the DB in order to present it in the view.
struct SwiftUIView: View {
#State m = AssignmentModel()
var body: some View {
// use m
}
func loadAssignmentFromDB() {
m.retrieveFirstAssignment()
}
}
This is what I called "enhanced MVC with built-in MVVM".
It satisfies your pursuit of MVC and possibly MVVM, at the same time, with much less effort.
Now I'll argue why:
function, or more specifically, mutation is Control. you don't need an object called "Controller" to have C in MVC. Otherwise we might as well stick to UIKit for 10 more years.
this makes sense when your model is value type. nothing can mutate it without your specific say-so, e.g.; #State annotation. since the only way to mutate model is via these designated endpoints, your Control takes effect only in the function that mutates these endpoints.
Quoting from another reply:
It is true that SwiftUI is a much closer match with MVVM than with MVC. However, almost all example code in the Apple documentation is so simple the ViewModel (and/or Controller in MVC) is left out completely. Once you start creating bigger projects the need for something to bridge your Views and Models arises. However, IMO, the SwiftUI documentation does not (yet) fully address this in a satisfying way.
SwiftUI, if anything, enhances MVC. What is the purpose of having ViewModel?
a.) to have model view binding, which is present in my code snippet above.
b.) to manage states associated with object. if #State does not give you the impression that it is for you to manage state, then i don't know what will. it's funny how many MVVM devs are just blind to this. Just as you don't need View Controller for Control, you don't need ViewModel for VM. Design pattern is a #State of mind. Not about specific naming and rigid structure.
c.) say i'm open to MVVM without being based. which code snippet do you think have more chance in scaling to larger projects? my compact one or that suggested in another reply?
hint: think how many extra files, view models, observableobjects, glue codes, pass-around-vm-as-parameters, init function to accept vm as parameters, you are going to have. and these are just for you to write some of the code in another object. it says nothing about reducing or simplifying the task at hand. hell it does even tell you how to refactor your control codes, so you are most likely to just repeat whatever you did wrong in MVC all over again. did i mention ViewModel is a shared, reference type object with implicit state managements? so what's the point of having a value type model when you are just going to override it with a reference type model?
it's funny how MVVM devs say SwiftUI in its base form is not scalable to larger projects. keeping things simple is the only way to scale.
This is what I observed as the roadmap of dev progression in 2020.
Day1: beginner
Day2: google some, drop MVC
Day3: google some more, SwiftUI not scalable
Day4: OK, I need MVVM+RxSwift+Coordinator+Router+DependencyInjection to avoid SDK short-comings.
My suggestion, due to this seems like a common beginner question, is to learn to walk before you run.
I've personally seen RxSwift developers move controller code to view so that controller appears "clean", and need 3 third-party libraries (one is a custom fork) to send a http GET.
Design pattern means nothing if you can't get simple things simple.
To me this is a very good question. It is true that SwiftUI is a much closer match with MVVM than with MVC. However, almost all example code in the Apple documentation is so simple the ViewModel (and/or Controller in MVC) is left out completely. Once you start creating bigger projects the need for something to bridge your Views and Models arises. However, IMO, the SwiftUI documentation does not (yet) fully address this in a satisfying way. I would love other developers to correct me or expand on this (I'm still learning), but here's what I found out so far:
For managing updating your views in non-example project you almost always want to use ObservableObject/ObservedObject.
Views should only observe an object if they need to be updated if it changes. It is better if you can delegate the updates to a child view.
It may be tempting to create a large ObservableObject and add #Published for all of its properties. However, this means that a view that observes that object gets updated (sometimes visibly) even if a property changes on which the view does not even depend.
Binding is the most natural interface for Views that represent controls that can modify data. Beware that a Binding does NOT trigger updating views. Updating the view should be managed either #State or #ObservedObject (this can done by the parent view of the control).
Constants are the natural interface for Views that only display data (and not modify it).
Here is how I would apply this to your example:
import SwiftUI
//
// Helper class for observing value types
//
class ObservableValue<Value: Hashable>: ObservableObject {
#Published var value: Value
init(initialValue: Value) {
value = initialValue
}
}
//
// Model
//
struct Assignment {
let name : String
let notes: String
}
//
// ViewModel?
//
// Usually a view model transforms data so it is usable by the view. Strings are already
// usable in our components. The only change here is to wrap the strings in an
// ObservableValue so views can listen for changes to the individual properties.
//
// Note: In Swift you often see transformations of the data implemented as extensions to
// the model rather than in a separate ViewModel.
class AssignmentModelView {
var name : ObservableValue<String>
var notes: ObservableValue<String>
init(assignment: Assignment) {
name = ObservableValue<String>(initialValue: assignment.name)
notes = ObservableValue<String>(initialValue: assignment.notes)
}
var assignment: Assignment {
Assignment(name: name.value, notes: notes.value)
}
}
//
// Controller
//
// Publish the first assignment so Views depending on it can update whenever we change
// the first assignment (**not** update its properties)
class AssignmentController: ObservableObject {
#Published var firstAssignment: AssignmentModelView?
func retrieveFirstAssignment() {
let assignment = Assignment(name: "My First Assignment", notes: "Everyone has to start somewhere...")
firstAssignment = AssignmentModelView(assignment: assignment)
}
}
struct ContentView: View {
// In a real app you should use dependency injection here
// (i.e. provide the assignmentController as a parameter)
#ObservedObject var assignmentController = AssignmentController()
var body: some View {
NavigationView {
VStack {
// I prefer to use `map` instead of conditional views, since it
// eliminates the need for forced unwrapping
self.assignmentController.firstAssignment.map { assignmentModelView in
Form {
ObservingTextField(title: "Assignment Name", value: assignmentModelView.name)
ObservingTextField(title: "Notes", value: assignmentModelView.notes)
}
}
Button(action: { self.retrieveFirstAssignment() }) {
Text("Get The First Assignment !")
}
}
.navigationBarTitle("First Assignment")
}
}
func retrieveFirstAssignment() {
assignmentController.retrieveFirstAssignment()
}
}
//
// Wrapper for TextField that correctly updates whenever the value
// changes
//
struct ObservingTextField: View {
let title: String
#ObservedObject var value: ObservableValue<String>
var body: some View {
TextField(title, text: $value.value)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This may be overkill for your app. There is a more straightforward version, but it has the
disadvantage that TextFields are updated even though their contents hasn't changed. In
this particular example I do not think that matters much. For larger projects it may
become important, not (just) for performance reasons, but updates are sometimes very
visible. For reference: here's the simpler version.
import SwiftUI
// Model
struct Assignment {
let name : String
let notes: String
}
// ViewModel
class AssignmentViewModel: ObservableObject {
#Published var name : String
#Published var notes: String
init(assignment: Assignment) {
name = assignment.name
notes = assignment.notes
}
}
// Controller
class AssignmentController: ObservableObject {
#Published var firstAssignment: AssignmentViewModel?
func retrieveFirstAssignment() {
let assignment = Assignment(name: "My First Assignment", notes: "Everyone has to start somewhere...")
firstAssignment = AssignmentViewModel(assignment: assignment)
}
}
struct ContentView: View {
// In a real app you should use dependency injection here
// (i.e. provide the assignmentController as a parameter)
#ObservedObject var assignmentController = AssignmentController()
var body: some View {
NavigationView {
VStack {
self.assignmentController.firstAssignment.map { assignmentModelView in
FirstAssignmentView(firstAssignment: assignmentModelView)
}
Button(action: { self.retrieveFirstAssignment() }) {
Text("Get The First Assignment !")
}
}
.navigationBarTitle("First Assignment")
}
}
func retrieveFirstAssignment() {
assignmentController.retrieveFirstAssignment()
}
}
struct FirstAssignmentView: View {
#ObservedObject var firstAssignment: AssignmentViewModel
var body: some View {
Form {
TextField("Assignment Name", text: $firstAssignment.name)
TextField("Notes", text: $firstAssignment.notes)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I had the very same question and I came accross an excellent paper by Matteo Manferdini in which he describes how to use the MVC model in SwiftUI.
I used his paper to refactor a Pizza app that uses CoreData. And even though I’m still a beginner in SwiftUI it gave me a very good understanding of how to implement MVC. You can find Matteo’s paper here.

SwiftUI custom View's ViewBuilder doesn't re-render/update on subclassed ObservedObject update

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.

SwiftUI - ObservableObject performance issues

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.

Resources