Ashley Mills
Ashley Mills

Reputation: 53111

How to add an observable property when other properties change

I have the following model object that I use to populate a List with a Toggle for each row, which is bound to measurement.isSelected

final class Model: ObservableObject {

    struct Measurement: Identifiable {
        var id = UUID()
        let name: String
        var isSelected: Binding<Bool>

        var selected: Bool = false

        init(name: String) {
            self.name = name

            let selected = CurrentValueSubject<Bool, Never>(false)
            self.isSelected = Binding<Bool>(get: { selected.value }, set: { selected.value = $0 })
        }
    }

    @Published var measurements: [Measurement]
    @Published var hasSelection: Bool = false  // How to set this?

    init(measurements: [Measurement]) {
        self.measurements = measurements
    }
}

I'd like the hasSelection property to be true whenever any measurement.isSelected is true. I'm guessing somehow Model needs to observe changes in measurements and then update its hasSelection property… but I've no idea where to start!

The idea is that hasSelection will be bound to a Button to enable or disable it.


Model is used as follows…

struct MeasurementsView: View {

    @ObservedObject var model: Model

    var body: some View {
        NavigationView {
            List(model.measurements) { measurement in
                MeasurementView(measurement: measurement)
            }
            .navigationBarTitle("Select Measurements")
            .navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: $model.hasSelection, label: {
                Text("Next")
            }))
        }
    }
}

struct MeasurementView: View {
    let measurement: Model.Measurement
    var body: some View {
        HStack {
            Text(measurement.name)
                .font(.subheadline)
            Spacer()
            Toggle(measurement.name, isOn: measurement.isSelected)
                .labelsHidden()
        }
    }
}

For info, here's a screenshot of what I'm trying to achieve. A list of selectable items, with a navigation link that is enabled when one or more is selected, and disabled when no items are selected.

enter image description here

Upvotes: 1

Views: 271

Answers (2)

user3441734
user3441734

Reputation: 17534

@user3441734 hasSelection should ideally be a get only property, that is true if any of measurement.isSelected is true

struct Data {
    var bool: Bool
}
class Model: ObservableObject {
    @Published var arr: [Data] = []
    var anyTrue: Bool {
        arr.map{$0.bool}.contains(true)
    }
}

example (as before) copy - paste - run

import SwiftUI

struct Data: Identifiable {
    let id = UUID()
    var name: String
    var on_off: Bool
}

class Model: ObservableObject {
    @Published var data = [Data(name: "alfa", on_off: false), Data(name: "beta", on_off: false), Data(name: "gama", on_off: false)]
    var bool: Bool {
        data.map {$0.on_off} .contains(true)
    }
}



struct ContentView: View {
    @ObservedObject var model = Model()
    var body: some View {
        VStack {
        List(0 ..< model.data.count) { idx in
            HStack {
                Text(verbatim: self.model.data[idx].name)
                Toggle(isOn: self.$model.data[idx].on_off) {
                    EmptyView()
                }
            }
        }
            Text("\(model.bool.description)").font(.largeTitle).padding()
        }
    }
}

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

When the model.data is updated

@Published var data ....

its publisher calls objectWillChange on ObservableObject.

Next SwiftUI recognize that ObservedObject needs the View to be "updated". The View is recreated, and that will force the model.bool.description will have fresh value.

LAST UPDATE

change this part of code

struct ContentView: View {
    @ObservedObject var model = Model()
    var body: some View {
        NavigationView {
        List(0 ..< model.data.count) { idx in
            HStack {
                Text(verbatim: self.model.data[idx].name)
                Toggle(isOn: self.$model.data[idx].on_off) {
                    EmptyView()
                }
            }
            }.navigationBarTitle("List")
            .navigationBarItems(trailing:

                NavigationLink(destination: Text("next"), label: {
                    Text("Next")
                }).disabled(!model.bool)

            )
        }
    }
}

and it is EXACTLY, WHAT YOU HAVE in your updated question enter image description here

Try it on real device, otherwise the NavigationLink is usable only once (this is well known simulator bug in current Xcode 11.3.1 (11C504)).

Upvotes: 1

Natalia Panferova
Natalia Panferova

Reputation: 1184

The problem with your code at the moment is that even if you observe the changes to measurements, they will not get updated when the selection updates, because you declared the var isSelected: Binding<Bool> as a Binding. This means that SwiftUI is storing it outside of your struct, and the struct itself doesn't update (stays immutable).

What you could try instead is declaring @Published var selectedMeasurementId: UUID? = nil on your model So your code would be something like this:


import SwiftUI
import Combine

struct NextView: View {
    var body: some View {
        Text("Next View")

    }
}

struct MeasurementsView: View {

    @ObservedObject var model: Model

    var body: some View {

        let hasSelection = Binding<Bool> (
            get: {
                self.model.selectedMeasurementId != nil
            },
            set: { value in
                self.model.selectedMeasurementId = nil
            }
        )

        return NavigationView {
            List(model.measurements) { measurement in
                MeasurementView(measurement: measurement, selectedMeasurementId: self.$model.selectedMeasurementId)
            }
            .navigationBarTitle("Select Measurements")
            .navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: hasSelection, label: {
                Text("Next")
            }))
        }
    }
}

struct MeasurementView: View {


    let measurement: Model.Measurement
    @Binding var selectedMeasurementId: UUID?

    var body: some View {

        let isSelected = Binding<Bool>(
            get: {
                self.selectedMeasurementId == self.measurement.id
            },
            set: { value in
                if value {
                    self.selectedMeasurementId = self.measurement.id
                } else {
                    self.selectedMeasurementId = nil
                }
            }
        )

        return HStack {
            Text(measurement.name)
                .font(.subheadline)
            Spacer()
            Toggle(measurement.name, isOn: isSelected)
                .labelsHidden()
        }
    }
}

final class Model: ObservableObject {

    @Published var selectedMeasurementId: UUID? = nil

    struct Measurement: Identifiable {
        var id = UUID()
        let name: String

        init(name: String) {
            self.name = name
        }
    }

    @Published var measurements: [Measurement]


    init(measurements: [Measurement]) {
        self.measurements = measurements
    }
}

I'm not sure exactly how you want the navigation button in the navbar to behave. For now I just set the selection to nil when it's tapped. You can modify it depending on what you want to do.

If you want to support multi-selection, you can use a Set of selected ids instead.

Also, seems like the iOS simulator has some problems with navigation, but I tested on a physical device and it worked.

Upvotes: 1

Related Questions