Weird Behaviour in Swiftui+Combine When Class -> Struct

SwiftUI/Combine notification overwrites my struct with incorrect values

Look in the documentation for the Published property wrapper

There you will find the sentence:

When the property changes, publishing occurs in the property’s willSet
block, meaning subscribers receive the new value before it’s actually
set on the property

With the unspoken caveat that the value of the property will be set after the subscribers receive the new value.

So your $frame publisher fires in the willSet of the frame property and it is passed the value that frame will be set to after publishing happens. Your subscriber changes the frame of the object, and prints that new value in the "Writing" string.

But once the publishing is done the system does the set part of the property change and carefully overwrites the changes you made in your subscriber with the original value.

When you delay when your subscriber, then instead of having the subscription make the change immediately, it publishes a block on the main queue to be run later so you don't see the value change during the "current" event handling cycle that's changing the slider.

Found a strange behaviour of @State when combined to the new Navigation Stack - Is it a bug or am I doing it wrong?

Edited to add: smallest amount of code that will force the navigation stack to see views of the same type as brand new instead of caching them is to add .id(some Hashable) to the view declaration.

In this case Fruit is hashable so we can just add .id(fruit)

    NavigationStack(path: $fruitViewController.visibilityStack) {
...
.navigationDestination(for: Fruit.self) { fruit in FruitView(fruitViewController: fruitViewController, fruit: fruit).id(fruit)
}
}

I would say that Adkarma has a point. The view is not "deiniting" as one might expect because they've haven't simply loaded a new fruit. They've edited the actual navigation path.

The thing is the view's identity is being preserved because (simplification/educated guess warning) it is NavigationStack[0] as? FruitView and NavigationStack[0] as FruitView is still there. The NavigationStack will not fully clean it up until the RootView has reappeared. NavigationStack doesn't clean up any views until the new one has appeared.

So, if you change FruitViewController to:

    func openView(fruit: Fruit) {
visibilityStack.append(fruit)
}

The NavigationStack will work as expected and you will continue to add views to the stack - I've added a counter and the visibilityStack to the FruitView to make what's happening clearer as well as some messages to make it clearer when things get made and cleaned up.

struct ContentView: View {
@StateObject private var fruitController = FruitViewController()

var body: some View {
NavigationStack(path: $fruitController.visibilityStack) {
VStack {
...
}
.onAppear() {
print("RootView Appeared")
}
.navigationDestination(for: Fruit.self) {
fruit in FruitView(fruit: fruit).environmentObject(fruitController)
}
}
}

struct FruitView: View {
// the state that will change and not be initialised
@State private var showMoreText = false
@State private var counter = 0
@EnvironmentObject var fruitViewController: FruitViewController
var fruit: Fruit

var body: some View {
VStack {
Text("Selected fruit: " + fruit.id)
Text("How many updates: \(counter)")
if (showMoreText) {
Text("The text should disappear when moving to another fruit")
HStack(spacing: 10) {
Text("Who is in the fruitList")
ForEach(fruitViewController.fruitList) {
aFruit in Button(aFruit.id) {
counter += 1
fruitViewController.openView(fruit: aFruit)
}
}
}

HStack(spacing:10) {
Text("Who is in the visibility stack")
ForEach(fruitViewController.visibilityStack) {
aFruit in Button(aFruit.id) {
counter += 1
fruitViewController.openView(fruit: aFruit)
}
}

}
} else {
Button("Show other fruits", action: showButtons)
}
}.onDisappear() {
print("Fruit view \(fruit.id) is gone")
}.onAppear() {
print("Hi I'm \(fruit.id), I'm new here.")
//dump(env)
}
}

// let's change the state
func showButtons() {
showMoreText = true
}
}

But this doesn't seem to be the behavior Adkarma wants. They don't want to go deeper and deeper. They want a hard swap at the identical position? Correct? The NavigationStack seems to try to increase efficiency by not destroying an existing view of the same type, which of course leaves the @State objects intact.

The navigation path is being driven by a binding to an Array inside an ObservableObject. The NavigationStack continues to believe that it is at fruitViewController.visibilityStack[0] ... because it is. It doesn't seem to care about the content inside the wrapper beyond its Type.

The preserved FruitView will re-run the body code, but since it isn't a "new view" (It's still good old NavigationStack[0] as FruitView)it will not hard refresh the @State vars.

But "I zero'd that out!" you say, but NavigationStack seems to have copy of it that DIDN'T, and it won't until it can make a new view appear, which it doesn't need to because there is in fact still something at NavigationStack[0] right away again.

I think Adkarma is spot on that this is related to deep linking and here are some articles I quite liked about that:

  • https://www.pointfree.co/blog/posts/78-reverse-engineering-swiftui-s-navigationpath-codability
  • https://swiftwithmajid.com/2022/06/21/mastering-navigationstack-in-swiftui-deep-linking/

Also interesting:

  • https://swiftui-lab.com/swiftui-id/

For what it's worth, to "get it done" I would just reset the variables in View when there is a content change in the Nav array explicitly checking with an onReceive, but I agree that seems a bit messy. I also would be interested if anyone has a more elegant solution to a hard swap of a navigation path that ends up at the same type of view at the same "distance" from the root like Adkarma's example.


struct FruitView: View {
// the state that will change and not be initialised
@State private var showMoreText = false
@State private var counter = 0
@EnvironmentObject var fruitViewController: FruitViewController
var fruit: Fruit

var body: some View {
VStack {
Text("Selected fruit: " + fruit.id)
Text("How many updates: \(counter)")
if (showMoreText) {
Text("The text should disappear when moving to another fruit")
HStack(spacing: 10) {
Text("Who is in the fruitList")
ForEach(fruitViewController.fruitList) {
aFruit in Button(aFruit.id) {
counter += 1
fruitViewController.openView(fruit: aFruit)
}
}
}
} else {
Button("Show other fruits", action: showButtons)
}
}.onReceive(fruitViewController.$visibilityStack) { value in
counter = 0
showMoreText = false

}

}

// let's change the state
func showButtons() {
showMoreText = true
}
}

FWIW a NavigationPath will also be looking at position/type pair rather than complete content so you will still need to look at it with .onRecieve or use an .id(some Hashable) on the view calls.

Edited to add:
A strange thing the following function "works" (it send the user to the root view for a split second and then navigates back out), up until the time is decreased under the amount of time it takes the rootView to load.

    func jumpView(fruit: Fruit) {
visibilityStack.removeAll()
Task { @MainActor in
//The time delay may be device/app specific.
try? await Task.sleep(nanoseconds: 500_000_000)
visibilityStack.append(fruit)
}
}

SwiftUI withAnimation causes unexpected behaviour

The origin of the problem is the fact that self.blocks.remove(at:) returns a value that you are not handling. If you explicitly ignore that value, then your code works as expected whether or not the print statement is there.

withAnimation {
_ = self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
}

Swift has a feature that a closure with a single line of code returns the value of that statement as the return of the closure. So in this case, if you don't ignore the return value of remove(at:) it returns a Block and Block then becomes the returned type of the withAnimation closure.

withAnimation is defined like this:

func withAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result

It is a generic function that takes a closure. The return type of that closure determines the type of the generic placeholder which in turn determines the return type of withAnimation itself.

So, if you don't ignore the return type of remove(at:), the withAnimation<Block> function will return a Block.

If you ignore the return value of remove(at:), the statement becomes one that has no return, (that is, it returns () or Void). Thus, the withAnimation function become withAnimation<Void> and it returns Void.

Now, because the closure to BlockRow has only a single line of code when you delete the print statement, its return value is the return value of the single statement, which is now Void:

BlockRow(block: block) { b in
withAnimation {
_ = self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
}
}

and this matches the type that the closure to BlockRow is expecting for its onDelete closure.

In your original code, the print statement caused the closure to BlockRow to have 2 lines of code, thus avoiding Swift's feature of using the single line of code to determine the return type of the closure. You did get a warning that you weren't using the Block that was being returned from the withAnimation<Block> function. @Asperi's answer fixed that warning by assigning the Block returned by withAnimation<Block> to _. This has the same effect as my suggested solution, but it is handling the problem one level higher instead of at the source of the problem.


Why doesn't the compiler complain that you are ignoring the return value of remove(at:)?

remove(at:) is explicitly designed to allow you to discard the returned result which is why you don't get a warning that it returns a value that you aren't handling.

@discardableResult mutating func remove(at i: Self.Index) -> Self.Element

But as you see, this lead to the confusing result you encountered. You were using remove(at:) as if it returned Void, but it in fact was returning the Block that was removed from your array. This then lead to the whole chain of events that lead to your issue.

How do I execute a function on ClassB when something changes in ClassA?

You need a PassThroughSubject to allow your views to be triggered to do something when the @Published values change. Then, on your view, you use an .onReceive to trigger your functions.

import SwiftUI
import Combine

struct ContentView: View {
@EnvironmentObject var game: Game
@EnvironmentObject var scores: ScoresStore

func saveScore() {
scores.addScore(newScore: game.score)
}

var body: some View {
Button(action: { saveScore }) {
Text("New Game?")
}
.onReceive(game.objectDidChange, perform: { _ in
//Do something here
})
.onReceive(scores.objectDidChange, perform: { _ in
//Do something here
})
}
}

final class Game: NSObject, ObservableObject, Codable {

let objectDidChange = PassthroughSubject<Void, Never>()

var deck: [Card] = []

@Published var piles: [[Card]] = [[],[],[],[]] {
didSet {
self.objectDidChange.send()
}
}

var score: Int {...}
}

final class ScoresStore: NSObject, Codable, ObservableObject, Identifiable {

let objectDidChange = PassthroughSubject<Void, Never>()

@Published var highScores: [Score] = [] {
didSet {
self.objectDidChange.send()
}
}

func addScore(newScore: Int, date: Date = Date()) {
// Do things to add the score to the array
}
}

Every time Game.piles or ScoreStore.highScores updates, you will get a notice to the .onReceive in your view, so you can deal with it. I suspect you will want to keep track of the game score this way as well, and trigger on that change. You can then test for whatever you want and implement whatever action you desire. You will also note that I did this on a didSet. You will also see this as an objectWillSet placed into a willSet on the variable. Both work, but the willSet sends a trigger before the actual update, whereas didSet sends it after. It may not make a difference, but I have had this as an issue in the past. Lastly, I imported Combine at the top, but I did these in one file. Wherever you define the passThroughSubject you will need to import Combine. The view does not actually need to import it. Good luck.

Communication between ViewModels with SwiftUI and Combine (ObservableObject vs Binding)

I would like to suggest a few improvements to your architecture.

DISCLAIMER:
Note that the following implementation is a suggestion how to approach the Master-Detail problem. There are countless more approaches and this one is just one of severals which I would suggest.

When things become more complex, you probably would prefer a unidirectional data flow approach between your view model and view. This basically means, no two way bindings to the view state.

Unidirectional means, that your SwiftUI views deal basically with constant external state which they render without asking. Instead of mutating the backing variable from a two way Binding directly, views send actions (aka events) to the view model. The view model processes theses events and sends out a new view state taking the whole logic into account.

By the way, this unidirectional data flow is inherent to the MVVM pattern. So, when you use a View Model, you should not use two way bindings that mutate the "View State". Otherwise this would not be MVVM and using the term View Model would be incorrect or at least confusing.

The consequence is, that your views will not perform any logic, all logic is delegated to the view model.

In your Master - Detail problem, this also means, that NavigationLinks will not be directly performed by the Master View. Instead, the fact that a user has tapped the NavigationLink will be send to the view model as an action. The view model then decides, whether to show a detail view, or not, or demands to show an alert, a modal sheet, or what ever it deems necessary what the view has to render.

Likewise, if the user taps the "back" button, the view will not be immediately popped from the Navigation stack. Instead, the view model receives an action. Again, it decides what to do.

This approach lets you intercept the data flow at strategically important "locations" and let you handle the situation more easily and in a correct way.

In a Master-Detail problem, especially in your example where architectural decisions are yet to be made, there is always the question who (which component) is responsible to create a Detail View Model (if required) and which part composes the Detail View and the Detail View Model and dynamically puts this into the view system (somehow) and removes it again when done (if required).

If we make the proposition, that a View Model should create a Detail View Model, which IMHO is reasonable, and if we further assume, that a user can issue an action that eventually ends up showing a detail view and with the suggestions made before, a possible solution in SwiftUI may look as follows:

(Note, I will not use your example, but create a new one with more generic names. So, hopefully, you can see where your example maps into my example)

So, we need these parts

  • a master view
  • a master view model,
  • a detail view
  • a detail view model
  • possibly additional views for decomposing several aspects and separation of concerns

The master view:

struct MasterView: View {
let items: [MasterViewModel.Item]
let selection: MasterViewModel.Selection?
let selectDetail: (_ id: MasterViewModel.Item.ID) -> Void
let unselectDetail: () -> Void

...

The master view uses a "state" which is comprised of the items it should draw in a list view. Additionally, it has two action functions selectDetail and unselectDetail. I am pretty sure it is clear, what these mean, but we will see later how they get used by the master view.

Additionally, we have a Selection property, which is an optional, and you might guess what it means: when it is not nil, it will render the detail view. If it is nil, it does not render a detail view. Pretty easy. Again, hold on where we see how it is used and what it is precisely.

When we look at the body of the master view, we implement the NavigationLink in a special form, so that we fulfil our unidirectional data flow requirement:

    var body: some View {
List {
ForEach(items, id: \.id) { element in
NavigationLink(
tag: element.id,
selection: link()) {
if let selection = self.selection {
DetailContainerView(
viewModel: selection.viewModel)
}
} label: {
Text("\(element.name)")
}
}
}
}

The NavigationLink uses the "selectable destination" form, whose signature is

init<V>(tag: V, selection: Binding<V?>, destination: () -> Destination, label: () -> Label)

This creates a navigation link that presents the destination view when a bound selection variable equals a given tag value.

See docs here.

The tag is the unique id of the item (here element.id).
The selection parameter, which is a Binding<Item.ID?> is the result of the function link() which will be shown below:

    func link() -> Binding<MasterViewModel.Item.ID?> {
Binding {
self.selection?.id
} set: { id in
print("link: \(String(describing: id))")
if let id = id {
selectDetail(id)
} else {
unselectDetail()
}
}
}

As you can see, link returns the proper binding. However, one crucial fact you can see here is, that we do not use "two way bindings". Instead, we route the actions that would mutate the backing variable of the binding to action functions. These actions will eventually be performed by the view model, which we will see later.

Please note the two action functions:

selectDetail(:) and

unselectDetail()

The getter of the binding works as usual: it just returns the id of the item.

This above, and the implementation of the two actions are enough to make push and pop from the Navigation stack work.

Need to edit the items, or pass some data from the Detail View to the Master View?
Just use this:

unselectDetail(mutatedItem: Item)

and an internal @Sate var item: Items in the Detail View plus logic in the Detail View Controller, or let the master and detail view model communicate to each other (see below).

With these parts, the Master View is complete.

But what is this Selection thingy?

This value will be created by the Master View Model. It is defined as follows:

    struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}

So, pretty easy. What's important to note is, that there is a Detail View Model. And since the Master View Model creates this "selection", it also has to create the detail view model - as our proposition has stated above.

Here, we make the assumption, that a view model has enough information at hand at the right time to create a fully configured detail (or child) view model.

The Master View Model

This view model has a few responsibilities. I will show the code, which should be pretty self-explanatory:

final class MasterViewModel: ObservableObject {

struct ViewState {
var items: [Item] = []
var selection: Selection? = nil
}

struct Item: Identifiable {
var id: Int
var name: String
}

struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}

@Published private(set) var viewState: ViewState

init(items: [Item]) {
self.viewState = .init(items: items, selection: nil)
}

func selectDetail(id: Item.ID) {
guard let item = viewState.items.first(where: { id == $0.id } ) else {
return
}
let detailViewModel = DetailViewModel(
item: .init(id: item.id,
name: item.name,
description: "description of \(item.name)",
image: URL(string: "a")!)
)
self.viewState.selection = Selection(
id: item.id,
viewModel: detailViewModel)
}

func unselectDetail() {
self.viewState.selection = nil
}
}

So, basically, it has a ViewState, which is precisely the "single source of truth" from the perspective of the view which has to just render this thing, without asking any questions.

This view state also contains the "Selection" value. Honestly, we may debate whether this is part of the view state or not, but I made it short, and put it into the view state, and thus, the view model only publishes one value, the View State. This makes this implementation more suitable to be refactored into a generic ..., but I don't want to distress.

Of course, the view model implements the effects of the action functions

selectDetail(:) and unselect().

It also has to create the Detail View Model. In this example, it just fakes it.

There's not much else to do for a Master View Model.

Detail View

The detail view is just for demonstration and as short as possible:

struct DetailView: View {
let item: DetailViewModel.Item

var body: some View {
HStack {
Text("\(item.id)")
Text("\(item.name)")
Text("\(item.description)")
}
}
}

You may notice, that it uses a constant view state (let item).
In your example, you may want to have actions, like "save" or something that is performed by the user.

Detail View Model

Also, pretty simple. Here, in your problem, you may want to put more logic in which handles the user's actions.

final class DetailViewModel: ObservableObject {

struct Item: Identifiable {
var id: Int
var name: String
var description: String
var image: URL
}

struct ViewState {
var item: Item
}

@Published private(set) var viewState: ViewState

init(item: Item) {
self.viewState = .init(item: item)
}

}

Caution: over simplification!

Here, in this example, the two view models don't communicate with each other. In a more practical solution, you may have more complicated things to solve, which involve communication between these view models. You would likely not implement this directly in the View Models, rather implement "Stores" which have Inputs, State, and possibly Outputs, perform their logic using finite state machines, and which can be interconnected so you have a system of "States" which eventually comprise your "AppState" which publishes its state to the view models, which in turn transform this to the view state for their views.

Wiring up

Here, some helper views come into play. They just help to wire up the view models with the views:

struct DetailContainerView: View {
@ObservedObject private(set) var viewModel: DetailViewModel

var body: some View {
DetailView(item: viewModel.viewState.item)
}
}

This sets up the view state, BUT ALSO separates the Detail View from the Detail View Model, since the view does not need to know anything about a view model. This makes it more easy to reuse the DetailView as a component.

struct MasterContainerView: View {
@ObservedObject private(set) var viewModel: MasterViewModel

var body: some View {
MasterView(
items: viewModel.viewState.items,
selection: viewModel.viewState.selection,
selectDetail: viewModel.selectDetail(id:),
unselectDetail: viewModel.unselectDetail)
}
}

Same here, decouple the MasterView from the MasterViewModel and setup actions and view state.

For your playgrounds:

struct ContentView: View {
@StateObject var viewModel = MasterViewModel(items: [
.init(id: 1, name: "John"),
.init(id: 2, name: "Bob"),
.init(id: 3, name: "Mary"),
])

var body: some View {
NavigationView {
MasterContainerView(viewModel: viewModel)
}
.navigationViewStyle(.stack)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())

Have fun! ;)

SwiftUI: ViewModifier doesn't listen to onReceive events

ok, this works for me:

func body(content: Content) -> some View {
content
.onAppear() // <--- this makes it work
.onReceive(viewModel.$myProperty) { theValue in
print("-----> The Value is \(theValue)") // <--- this will be executed
}
}

Pass @Published where @Binding is required (SwiftUI, Combine)

If I understand your question correctly, you are probably looking for something like that:

final class ViewModel: ObservableObject {

@Published var sourceProperty: String = ""
private lazy var logic = Logic(data: $sourceProperty)
private var cancellable: AnyCancellable?

init() {
cancellable = logic.$output
.sink { result in
print("got result: \(result)")
}
}

}

final class Logic: ObservableObject {

@Published private(set) var output: String = ""

init(data: Published<String>.Publisher) {
data
.map { $0 + "ABCDE" }
.assign(to: &$output)
}
}

SwiftUI ChildView Not recalculated on state change

This was a silly mistake on my part. The problem was actually immature implementation of Equatable which is actually no longer needed as of Swift 4.1+ (same for Hashable). I originally added it to ensure I could compare Cards (needed for index(of:) method), however, since I was only checking comparing id's, it was messing with SwiftUI's internal comparison algorithm for redrawing. SwiftUI too was using my Equatable implementation and thinking oh the Card actually has not changed!



Related Topics



Leave a reply



Submit