How to handle onChange of Text event in SwiftUI? - ios

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)
}
}

Related

Why is this SwiftUI state not updated when passed as a non-binding parameter?

Here's what I want to do:
Have a SwiftUI view which changes a local State variable
On a button tap, pass that variable to some other part of my application
However, for some reason, even though I update the state variable, it doesn't get updated when it's passed to the next view.
Here's some sample code which shows the problem:
struct NumberView: View {
#State var number: Int = 1
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: number)
}
}
}
}
struct SomeView: View {
let number: Int
var body: some View {
Text("\(number)")
}
}
If you tap on "Change Number", it updates the local state to 99. But when I create another view and pass this as a parameter, it shows 1 instead of 99. What's going on?
Some things to note:
If you uncomment Text("\(number)"), it works. But this shouldn't be necessary IMO.
It also works if you make SomeView use a binding. But for my app, this won't work. My actual use case is a 'select game options' view. Then, I will create a non-SwiftUI game view and I want to pass in these options as parameters. So, I can't have bindings all the way down my gaming code just because of this bug. I want to just capture what the user enters and create a Parameters object with that data.
It also works if you make it a navigationDestination instead of a fullScreenCover. ¯\(ツ)/¯ no idea on that one...
A View is a struct, therefore its properties are immutable, so the view can not change its own properties. This is why changing the property named number from inside the body of the view needs this property to be annotated with a #State property wrapper. Thanks to Swift and SwiftUI, transparent read and write callbacks let the value being seen changed. So you must not pass number as a parameter of SomeView() when calling fullScreenCover(), but pass a reference to number, for the callbacks to be systematically called: $number. Since you are not passing an integer anymore to construct struct SomeView, the type of the property named number in this struct can not any longer be an integer, but must be a reference to an integer (namely a binding): use the #Binding annotation for this.
So, replace SomeView(number: number) by SomeView(number: $number) and let number: Int by #Binding var number: Int to do the job.
Here is the correct source code:
import SwiftUI
struct NumberView: View {
#State var number: Int = 1
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: $number)
}
}
}
}
struct SomeView: View {
#Binding var number: Int
var body: some View {
Text("\(number)")
}
}
After all that said to obtain a valid source code, their is a little trick that has not been explained up to now: if you simply replace in your source code Text("Change Number") by Text("Change Number \(number)"), without using $ reference nor #Binding keywords anywhere, you will see that the problem is also automatically solved! No need to use #binding in SomeView! This is because SwiftUI makes optimizations when building a tree of views. If it knows that the displayed view changed (not only its properties), it will compute the view with updated #State values. Adding number to the button label makes SwiftUI track changes of the number state property and it now updates its cached value to display the Text button label, therefore this new value will be correctly used to create SomeView. All of that may be considered as strange things, but is simply due to optimizations in SwiftUI. Apple does not fully explain how it implements optimizations building a tree of views, there are some informations given during WWDC events but the source code is not open. Therefore, you need to strictly follow the design pattern based on #State and #Binding to be sure that the whole thing works like it should.
All of that said again, one could argue that Apple says that you do not have to use #Binding to pass a value to a child view if this child view only wants to access the value: share the state with any child views that also need access, either directly for read-only access, or as a binding for read-write access (https://developer.apple.com/documentation/swiftui/state). This is right, but Apple says in the same article that you need to place [state] in the highest view in the view hierarchy that needs access to the value. With Apple, needing to access a value means that you need it to display the view, not only to do other computations that have no impact on the screen. This is this interpretation that lets Apple optimize the computation of the state property when it needs to update NumberView, for instance when computing the content of the Text("Change Number \(number)") line. You could find it really tricky. But there is a way to understand that: take the initial code you wrote, remove the #State in front of var number: Int = 1. To compile it, you need to move this line from inside the struct to outside, for instance at the very first line of your source file, just after the import declaration. And you will see that it works! This is because you do not need this value to display NumberView. And thus, it is perfectly legal to put the value higher, to build the view named SomeView. Be careful, here you do not want to update SomeView, so there is no border effects. But it would not work if you had to update SomeView.
Here is the code for this last trick:
import SwiftUI
// number is declared outside the views!
var number: Int = 1
struct NumberView: View {
// no more state variable named number!
// No more modification: the following code is exactly yours!
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: number)
}
}
}
}
struct SomeView: View {
let number: Int
var body: some View {
Text("\(number)")
}
}
This is why you should definitely follow the #State and #Binding design pattern, taking into account that if you declare a state in a view that does not use it to display its content, you should declare this state as a #Binding in child views even if those children do not need to make changes to this state. The best way to use #State is to declare it in the highest view that needs it to display something: never forget that #State must be declared in the view that owns this variable; creating a view that owns a variable but that does not have to use it to display its content is an anti-pattern.
Since number isn't read in body, SwiftUI's dependency tracking detect it. You can give it a nudge like this:
.fullScreenCover(isPresented: $showNumber) { [number] in
Now a new closure will be created with the updated number value whenever number changes. Fyi the [number] in syntax is called a "capture list", read about it here.
Nathan Tannar gave me this explanation via another channel which I think gets to the crux of my problem. It does seem that this is a SwiftUI weirdness caused by knowing when and how it updates views based on state. Thanks Nathan!
It’s because the number isn’t “read” in the body of the view. SwiftUI is smart in that it only triggers view updates when a dependency of the view changes. Why this causes issues with the fullScreenCover modifier is because it captures an #escaping closure for the body. Which means it’s not read until the cover is presented. Since its not read the view body will not be re-evaluated when the #State changes, you can validate this by setting a breakpoint in the view body. Because the view body is not re-evaluated, the #escaping closure is never re-captured and thus it will hold a copy of the original value.
As a side note, you’ll find that once you present the cover for the first time and then dismiss, subsequent presentations will update correctly.
Arguably this seems like a SwiftUI bug, the fullScreenCover probably shouldn’t be #escaping. You can workaround by reading the number within the body, or wrapping the modifier with something like this, since here destination is not #escaping captured so the number will be read in the views body evaluation.
struct FullScreenModifier<Destination: View>: ViewModifier {
#Binding var isPresented: Bool
#ViewBuilder var destination: Destination
func body(content: Content) -> some View {
content
.fullScreenCover(isPresented: $isPresented) {
destination
}
}
}

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

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.

Should i check that i assign different value to #Published property?

I have simple example of code in which i update #State and #Published property with same value (on button click). Now i see that then i assign the same value to #State there is no body call of view. But then i assign to #Published property then there is call on each assign. I know that it might be fast but still there might be an unnecessary calls to body of view.
So should i check that i actually change value of #Published property or there is some other ways?
class Object: ObservableObject {
#Published var state: Bool = false
func setState() {
state = false
}
}
struct ContentView: View {
#State var state: Bool = false
#ObservedObject var obj = Object()
var body: some View {
print(Self._printChanges())
return VStack {
Text("\(String(state))")
Button("Tap") {
self.state = false
obj.setState()
}
}
}
}
Output on tap clicks (3 times):
ContentView: _obj changed.
()
ContentView: _obj changed.
()
ContentView: _obj changed.
()
SwiftUI is declarative, so it could be argued that this specific behaviour is an implementation detail and I would not worry about it.
Just because SwiftUI is calling body does not necessarily mean that your UI is being redrawn.
Indeed, if each view has the same "Identity" with unchanged data, you do not need to worry about additional SwiftUI draw calls.
The system will know nothing has changed and won't incur UI overhead.
You don't need to work on patching around this unless you have specifically profiled this as being a performance bottleneck in your app.
Don't optimize anything unless you have good reason to, otherwise you're just wasting time (as there may be other bottlenecks to work on first).

Observing Binding or State variables

I'm looking for a way of observing #State or #Binding value changes in onReceive. I can't make it work, and I suspect it's not possible, but maybe there's a way of transforming them to Publisher or something while at the same time keeping the source updating value as it's doing right now?
Below you can find some context why I need this:
I have a parent view which is supposed to display half modal based on this library: https://github.com/AndreaMiotto/PartialSheet
For this purpose, I've created a #State private var modalPresented: Bool = false and I'm using it to show and hide this modal view. This works fine, but my parent initializes this modal immediately after initializing self, so I completely loose the onAppear and onDisappear modifiers. The problem is that I need onAppear to perform some data fetching every time this modal is being presented (ideally I'd also cancel network task when modal is being dismissed).
use ObservableObject / ObservedObject instead.
an example
import SwiftUI
class Model: ObservableObject {
#Published var txt = ""
#Published var editing = false
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
TextField("Email", text: self.$model.txt, onEditingChanged: { edit in
self.model.editing = edit
}).onReceive(model.$txt) { (output) in
print("txt:", output)
}.onReceive(model.$editing) { (output) in
print("editing:", output)
}.padding().border(Color.red)
}
}

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