alenm
alenm

Reputation: 1043

@Binding is being published twice on @StateObject vs View?

Overview

import SwiftUI
import Combine

struct Consumption: Identifiable, Hashable {
    var id: UUID = UUID()
    var name: String
}

struct Frequency: Identifiable, Hashable {
    var id: UUID = UUID()
    var name: String
}

struct Selection: Identifiable {
    var id: UUID = UUID()
    var consumption: Consumption
    var frequency: Frequency
}


class SomeModel: ObservableObject {
    
    let consump: [Consumption] = ["Coffee","Tea","Beer"].map { str in Consumption(name: str)}
    let freq: [Frequency] = ["daily","weekly","monthly"].map { str in Frequency(name: str)}

    @Published var consumption: Consumption?
    @Published var frequency: Frequency?
    @Published var selection: Selection?
    
    private var cancellable = Set<AnyCancellable>()

    init(consumption: Consumption? = nil, frequency: Frequency? = nil, selection: Selection? = nil) {
        self.consumption = consumption
        self.frequency = frequency
        self.selection = selection
        
        $frequency
            .print()
            .sink { newValue in
                if let newValue = newValue {
                    print("THIS SHOULD APPEAR ONCE")
                    print("---------------")
                    self.selection = .init(consumption: self.consumption!, frequency: newValue)
                } else {
                    print("NOTHING")
                    print("---------------")
                }
            }.store(in: &cancellable)
    }
    
}

Problem

The @StateObject on the view is publishing the property twice. (See code below)


struct ContentView: View {
    
    @StateObject var model: SomeModel = .init()
    
    var body: some View {
        NavigationSplitView {
            VStack {
                List(model.consump, selection: $model.consumption) { item in
                    NavigationLink(value: item) {
                        Label(item.name, systemImage: "circle.fill")
                    }
                }
            }
        } content: {
            switch model.consumption {
            case .none:
                Text("nothing")
            case .some(let consumption):
                List(model.freq, id:\.id, selection: $model.frequency) { item in
                    NavigationLink(item.name, value: item)
                }
                .navigationTitle(consumption.name)
            }
        } detail: {
            switch model.selection {
            case .none:
                Text("nothing selected")
            case .some(let selection):
                VStack {
                    Text(selection.consumption.name)
                    Text(selection.frequency.name)
                }
            }
        }

    }
}

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

example of twice published

My Solution

If I change the View by creating @State properties on the view and then use OnChange modifier to update the model I get the property to update once. Which is what I want. (see code below)

 struct ContentView: View {
     
     @StateObject var model: SomeModel = .init()
     
     @State private var consumption: Consumption?
     @State private var frequency: Frequency?

     var body: some View {
         NavigationSplitView {
             VStack {
                 List(model.consump, selection: $consumption) { item in
                     NavigationLink(value: item) {
                         Label(item.name, systemImage: "circle.fill")
                     }
                 }
             }.onChange(of: consumption) { newValue in
                 self.model.consumption = newValue
             }
         } content: {
             switch model.consumption {
             case .none:
                 Text("nothing")
             case .some(let consumption):
                 List(model.freq, id:\.id, selection: $frequency) { item in
                     NavigationLink(item.name, value: item)
                 }
                 .navigationTitle(consumption.name)
                 .onChange(of: frequency) { newValue in
                     self.model.frequency = newValue
                 }
             }
         } detail: {
             switch model.selection {
             case .none:
                 Text("nothing selected")
             case .some(let selection):
                 VStack {
                     Text(selection.consumption.name)
                     Text(selection.frequency.name)
                 }
             }
         }

     }
 }

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

Question(s) / Help

Is the behaviour of @Binding different between @StateObject + @Published vs View + @State?

I don't understand why in my previous View the property was being published twice.

My solution is a bit more work but it works by using @State and onChange view modifiers.

Upvotes: 3

Views: 268

Answers (1)

burnsi
burnsi

Reputation: 7745

Well I can´t completely explain why this happens. It seems that List updates its selection twice.

Why? Unknown. ?May be a bug?.

Consider this debugging approach:

} content: {
        switch model.consumption {
        case .none:
            Text("nothing")
        case .some(let consumption):
            // Create a custom binding and connect it to the viewmodel
            let binding = Binding<Frequency?> {
                model.frequency
            } set: { frequency, transaction in
                model.frequency = frequency
                // this will print after the List selected a new Value
                print("List set frequency")
            }
                                      // use the custom binding here
            List(model.freq, id:\.id, selection: binding) { item in
                NavigationLink(item.name, value: item)
            }
            .navigationTitle(consumption.name)
        }

This will produce the following output:

receive subscription: (PublishedSubject)
request unlimited
receive value: (nil)
NOTHING
---------------
receive value: (Optional(__lldb_expr_7.Frequency(id: D9552E6A-71FE-407A-90F5-87C39FE24193, name: "daily")))
THIS SHOULD APPEAR ONCE
---------------
List set frequency
receive value: (Optional(__lldb_expr_7.Frequency(id: D9552E6A-71FE-407A-90F5-87C39FE24193, name: "daily")))
THIS SHOULD APPEAR ONCE
---------------
List set frequency

The reason you are not seeing this while using @State and .onChange is the .onChange modifier. It fires only if the value changes which it does not. On the other hand your Combine publisher fires every time no matter the value changed or not.

Solution:

You can keep your Combine approach and use the .removeDuplicates modifier.

$frequency
    .removeDuplicates()
    .print()
    .sink { newValue in

This will ensure the sink will only get called when new values are sent.

Upvotes: 1

Related Questions