Swiftui - How to Get Didset to Fire When Changing a @Published Struct

SwiftUI - is it possible to get didSet to fire when changing a @Published struct?

The property observer observes the property. The trouble goes from new Swift syntax related to property wrappers. In your case you try to observe if value of Published (which is a struct defining the specialized property wrapper) did change, not the value of the wrapped property.

If you need to monitor left or right values in PaddingRect, simply observe this values directly.

import SwiftUI

struct PaddingRect {
var left: CGFloat = 20 {
didSet {
print("left padding change from:", oldValue, "to:", left)
}
}
var right: CGFloat = 20 {
didSet {
print("right padding change from:", oldValue, "to:", right)
}
}
}

final class SomeStore : ObservableObject {
@Published var someOtherValue: String = "Waiting for didSet"
@Published var paddingRect:PaddingRect = PaddingRect()
}

struct ContentView: View {
@ObservedObject var store = SomeStore()

var body: some View {
VStack {
Spacer()

Rectangle()
.fill(Color.yellow)
.padding(.leading, store.paddingRect.left)
.padding(.trailing, store.paddingRect.right)
.frame(height: 100)

Text(store.someOtherValue)

HStack {
Button(action: {
// This doesn't call didSet
self.store.paddingRect.left += 20

// This does call didSet, ie. setting the whole thing
self.store.paddingRect = PaddingRect(
left: self.store.paddingRect.left + 20,
right: self.store.paddingRect.right
)

}) {
Text("Padding left +20")
}

Button(action: {
self.store.paddingRect.right += 20
}) {
Text("Padding right +20")
}
}

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Sample Image

Or take the advantage that Published projected value is Publisher and aply next modifier to any View

.onReceive(store.$paddingRect) { (p) in
print(p)
}

SwiftUI trigger function when model updates inside @Published property

Firstly - simplifying this code. Most of this code is unnecessary to reproduce the problem, and can't even be compiled. The code below is not the working code, but rather what we have to start and change later:

ContentView

struct ContentView: View {
var body: some View {
NavigationView {
PackagesView()
}
}
}

PackageService

class PackageService: ObservableObject {
let package: Package

init(package: Package) {
self.package = package
}

// Get placemarks from locations
func getPlacemarks() {
print("getPlacements called")
}
}

PackagesViewModel

class PackagesViewModel: ObservableObject {
@Published var results = [Package]()
}

PackagesView

struct PackagesView: View {
@StateObject var packagesViewModel = PackagesViewModel()

var body: some View {
VStack {
Button("Add new package") {
let number = packagesViewModel.results.count + 1
let new = Package(title: "title \(number)", description: "description \(number)")
packagesViewModel.results.append(new)
}

Button("Change random title") {
guard let randomIndex = packagesViewModel.results.indices.randomElement() else {
return
}

packagesViewModel.results[randomIndex].title = "new title (\(Int.random(in: 1 ... 100)))"
}

List(packagesViewModel.results, id: \.self) { package in
NavigationLink(destination: PackageView(packageService: PackageService(package: package))) {
Text(package.title)
}
}
}
}
}

PackageView

struct PackageView: View {
@ObservedObject var packageService: PackageService

var body: some View {
VStack {
Text("Title: \(packageService.package.title)")

Text("Description: \(packageService.package.description)")
}
}
}

Package

struct Package: Identifiable, Hashable {
let id = UUID()
var title: String
let description: String
}

Now, solving the problem. I fixed this issue by detecting the results changing with onChange(of:perform:). However, from here there is no way to access the PackageServices used in the view body.

To prevent this issue, the PackageServices are actually stored in PackagesViewModel, which logically makes more sense for the data flow. Now with PackageService also being a struct so the @Published works on the array for results, this now works.

See the code below:

PackageService (updated)

struct PackageService: Hashable {
var package: Package

init(package: Package) {
self.package = package
}

// Get placemarks from locations
mutating func getPlacemarks() {
print("getPlacements called")

// This function is mutating, feel free to set any properties in here
}
}

PackagesViewModel (updated)

class PackagesViewModel: ObservableObject {
@Published var results = [PackageService]()
}

PackagesView (updated)

struct PackagesView: View {
@StateObject var packagesViewModel = PackagesViewModel()

var body: some View {
VStack {
Button("Add new package") {
let number = packagesViewModel.results.count + 1
let new = Package(title: "title \(number)", description: "description \(number)")
packagesViewModel.results.append(PackageService(package: new))
}

Button("Change random title") {
guard let randomIndex = packagesViewModel.results.indices.randomElement() else {
return
}

let newTitle = "new title (\(Int.random(in: 1 ... 100)))"
packagesViewModel.results[randomIndex].package.title = newTitle
}

List($packagesViewModel.results, id: \.self) { $packageService in
NavigationLink(destination: PackageView(packageService: $packageService)) {
Text(packageService.package.title)
}
}
}
.onChange(of: packagesViewModel.results) { _ in
for packageService in $packagesViewModel.results {
packageService.wrappedValue.getPlacemarks()
}
}
}
}

PackageView (updated)

struct PackageView: View {
@Binding var packageService: PackageService

var body: some View {
VStack {
Text("Title: \(packageService.package.title)")

Text("Description: \(packageService.package.description)")
}
}
}

SwiftUI View Property willSet & didSet property observers not working

EDIT:
On iOS 14 property observers work the same as they did in iOS 13. But, we now have the .onChange(of:perform:) as a replacement. Docs

Text(self.myString).onChange(of: self.myString) { newValue in print("myString changed to: \(newValue)") }

Property observers on basic vars technically work in SwiftUI. If you do something like var view = MyTestView(...) and then view.FSAC = "updated" the the observers will fire (I've verified this).

However, typically with SwiftUI you construct the View (which is a struct not a class) within body during each layout pass. In your case var body: some View { MyTestView(FSAC: "FSAC Value") }.

Property observers do not fire during init, and therefore they aren't usually useful in SwiftUI Views.

If you would like to update some sort of State during init, take a look at this answer.

Is there an alternative to Combine's @Published that signals a value change after it has taken place instead of before?

You can write your own custom property wrapper:

import Combine

@propertyWrapper
class DidSet<Value> {
private var val: Value
private let subject: CurrentValueSubject<Value, Never>

init(wrappedValue value: Value) {
val = value
subject = CurrentValueSubject(value)
wrappedValue = value
}

var wrappedValue: Value {
set {
val = newValue
subject.send(val)
}
get { val }
}

public var projectedValue: CurrentValueSubject<Value, Never> {
get { subject }
}
}

Notifications not sent when changing an ObservedObject

As already suggested in the comments, you need to convert your class to a struct if you want to make it work: struct Stuff {. A struct is a value type, which makes it work well with ObservableObject, while a class is a reference type, which, well, doesn't work that well.

@Published properties need to be "pure" value types, if you want to be notified when something in their contents change. By "pure" I mean value types that are made of only of other value types, which are also "pure".

Why this is needed? It's because the @Published mechanism relies on the willSet/didSet property observers, and the property observers are executed only when the value they hold change.

With value types, any change in their structure is propagated upstream, resulting in the whole property being updated. This happens due to the semantics of the value types.

See this code snippet:

struct Question {
var title: String = ""
}

class Page {
var question = Question() {
didSet {
print("Question changed: \(question)")
}
}
}

let page = Page()
page.question.title = "@Published doesn't work"

Any change to the question members results in the whole property being replaces, this triggering the willSet/didSet property observers, so you can nicely monitor any changes in the data structure of question.

The above is, however, not true for classes. Classes have identity, they have a fixed memory location, unlike value types, which get copied all over the place when used. This means that changes in their internal structure are not reflected upstream, as the class instance storage doesn't change.

The only time the @Published observer is triggered for classes, is when you replace the object itself. Try this, and you'll see the notifications are fired:

doSomething.observedObject.stuff = Stuff() // this will print `Object has changed`

In SwiftUI, how do I know when a Picker selection was changed? Why doesn't didSet work?

I started typing this out earlier, and returned to find LuLuGaGa had beaten me to the punch. :D But since I have this anyway...

Main question: From the Swift Language Guide:

"When you assign a default value to a stored property, or set its
initial value within an initializer, the value of that property is set
directly, without calling any property observers."

So the property observer will not fire when the view is constructed. But when a @State variable changes, a new instance of the view is constructed (remember, views are structs, or value types). Thus, the didSet property observer is, practically speaking, not useful on @State properties.

What you want to do is create a class that conforms to ObservableObject, and reference it from your view with the @ObservedObject property wrapper. Because the class exists outside the struct, you can set property observers on its properties, and they will fire like you're expecting.

Question A: You can use just the enum if you make it conform to CaseIterable (see example below)

Question B: This appears to be a SwiftUI bug, as it happens with any Picker inside of a NavigationView/Form combo, as far as I can tell. I'd recommend reporting it to Apple.

Here is how I would remove the redundancy of the enum and array, and save the selection in UserDefaults:

extension ContentView {
// CaseIterable lets us use .allCases property in ForEach
enum TransmissionType: String, CaseIterable, Identifiable, CustomStringConvertible {
case automatic
case manual

// This lets us omit 'id' parameter in ForEach
var id: TransmissionType {
self
}

// This just capitalizes the first letter for prettier printing
var description: String {
rawValue.prefix(1).uppercased() + rawValue.dropFirst()
}
}

class SelectionModel: ObservableObject {
// Save selected type to UserDefaults on change
@Published var selectedTransmissionType: TransmissionType {
didSet {
UserDefaults.standard.set(selectedTransmissionType.rawValue, forKey: "TransmissionType")
}
}

// Load selected type from UserDefaults on initialization
init() {
if let rawValue = UserDefaults.standard.string(forKey: "TransmissionType") {
if let transmissionType = TransmissionType(rawValue: rawValue) {
self.selectedTransmissionType = transmissionType
return
}
}
// Couldn't load from UserDefaults
self.selectedTransmissionType = .automatic
}
}
}

Then your view just looks like

struct ContentView: View {
@ObservedObject var model = SelectionModel()

var body: some View {
NavigationView {
Form {
Section {
Picker(selection: $model.selectedTransmissionType, label: Text("Transmission Type")) {
ForEach(TransmissionType.allCases) { type in
Text(type.description)
}
}
}
}
}
}
}


Related Topics



Leave a reply



Submit