I was experimenting with Combine and SwiftUI but stuck in updating state basically I want to update my state in the view every time ObservableObject changes,
here's the example.
class XViewModel: ObservableObject {
#Published var tVal: Bool = false
private var cancellables = Set<AnyCancellable>()
func change() {
Just(true)
.delay(for: 3.0, scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to: \.tVal, on: self)
.store(in: &self.cancellables)
}
}
I have viewModel and one publisher and delayed publisher which triggers after 3 seconds.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
self.viewModel
.$tVal.assign(to: \.toggleVal, on: self)
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
}
}
What I expected is
viewModel.Just triggers
viewModel.tVal publisher triggers
view.toggleVal state triggers
updates UI
But it seems although everything is updated it doesn't update state. Is there any way to update State or it wasn't meant to be updated at all and I need to bind my views directly to the viewModel's tVal value which is publisher.
Thanks.
The #State is not ready in init to operate, use instead .onReceive as below.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
.onReceive(self.viewModel.$tVal) { newVal in // << here !!
self.toggleVal = newVal
}
}
}
Related
In my project i hold a large dict of items that are updated via grpc stream. Inside the app there are several places i am rendering these items to UI and i would like to propagate the realtime updates.
Simplified code:
struct Item: Identifiable {
var id:String = UUID().uuidString
var name:String
var someKey:String
init(name:String){
self.name=name
}
}
class DataRepository {
public var serverSymbols: [String: CurrentValueSubject<Item, Never>] = [:]
// method that populates the dict
func getServerSymbols(serverID:Int){
someService.fetchServerSymbols(serverID: serverID){ response in
response.data.forEach { (name,sym) in
self.serverSymbols[name] = CurrentValueSubject(Item(sym))
}
}
}
// background stream that updates the values
func serverStream(symbols:[String] = []){
someService.initStream(){ update in
DispatchQueue.main.async {
self.serverSymbols[data.id]?.value.someKey = data.someKey
}
}
}
}
ViewModel:
class SampleViewModel: ObservableObject {
#Injected var repo:DataRepository // injection via Resolver
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
func getItem(item:String) -> Item {
guard let item = repo.serverSymbols[item] else { return Item(name:"N/A")}
return ItemPublisher(item: item).data
}
}
class ItemPublisher: ObservableObject {
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:CurrentValueSubject<Item, Never>){
item
.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
Main View with subviews:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item: viewModel.getItem(item: item))
}
}
}
}
struct FavoriteCardView: View {
var item:Item
var body: some View {
VStack {
Text(item.name)
Text(item.someKey) // dynamic value that should receive the updates
}
}
}
I must've clearly missed something or it's a completely wrong approach, however my Item cards do not receive any updates (i verified the backend stream is active and serverSymbols dict is getting updated). Any advice would be appreciated!
I've realised i've made a mistake - in order to receive the updates i need to pass down the ItemPublisher itself. (i was incorrectly returning ItemPublisher.data from my viewModel's method)
I've refactored the code and make the ItemPublisher provide the data directly from my repository using the item key, so now each card is subscribing individualy using the publisher.
Final working code now:
class SampleViewModel: ObservableObject {
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
}
MainView and CardView:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item)
}
}
}
}
struct FavoriteCardView: View {
var itemName:String
#ObservedObject var item:ItemPublisher
init(_ itemName:String){
self.itemName = itemName
self.item = ItemPublisher(item:item)
}
var body: some View {
let itemData = item.data
VStack {
Text(itemData.name)
Text(itemData.someKey)
}
}
}
and lastly, modified ItemPublisher:
class ItemPublisher: ObservableObject {
#Injected var repo:DataRepository
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:String){
self.data = Item(name:item)
if let item = repo.serverSymbols[item] {
self.data = item.value
item.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
}
I am working on a project that is using Combine to get updates from Firebase Firestore. I have a StockListView, a StockListCellView, and a StockDetailView that I want updates to be registered in.
The StockListView holds StockListCellViews which push StockDetailsViews onto the stack. Each view also has a corresponding ViewModel where I am working with Combine.
My trouble is my StockDetailView is not receiving the updates from Combine and I can't see why. Below is a simplified version of the code for each view and viewModel. I think this has something to do with how I am assigning in the StockDetailViewModel but I can't figure it out. Any help would be appreciated.
StockListViewModel - Works Great
class StockListViewModel: ObservableObject {
#Published var stockRepository = StockRepository()
#Published var stockListCellViewModels = [StockListCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
stockRepository.$stocks.map { stocks in
stocks.map { stock in
StockListCellViewModel(stockDetailViewModel: StockDetailViewModel(stock: stock))
}
}
.assign(to: \.stockListCellViewModels, on: self)
.store(in: &cancellables)
}
}
StockListView - Works Great
struct StockListView: View {
#ObservedObject var stockRepository = StockRepository()
#ObservedObject var stockListVM = StockListViewModel()
var body: some View {
NavigationView {
List {
ForEach(stockListVM.stockListCellViewModels) { stockListCellVM in
NavigationLink(destination: StockDetailView(stockDetailVM: stockListCellVM.stockDetailViewModel)) {
StockListCell(stockListVM: stockListVM, stockListCellVM: stockListCellVM)
}
}
} // List
.listStyle(PlainListStyle())
.navigationBarTitle("stock")
} // NavigationView
} // View
}
StockListCellViewModel - Works Great
class StockListCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stockDetailViewModel: StockDetailViewModel
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stockDetailViewModel: StockDetailViewModel) {
self.stockDetailViewModel = stockDetailViewModel
self.stock = stockDetailViewModel.stock
stockDetailViewModel.$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
stockDetailViewModel.$stock.map { stock in
StockDetailViewModel(stock: stock)
}
.assign(to: \.stockDetailViewModel, on: self)
.store(in: &cancellables)
}
}
StockListCellView - Works Great
struct StockListCell: View {
#ObservedObject var stockListVM: StockListViewModel
#ObservedObject var stockListCellVM: StockListCellViewModel
var body: some View {
Text(stockListCellVM.stock.ticker)
}
}
StockDetailViewModel - Not Updating
class StockDetailViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stock: Stock) {
self.stock = stock
self.chartColor = UIColor()
$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
StockDetailView - Not Updating
struct StockDetailView: View {
#ObservedObject var stockDetailVM: StockDetailViewModel
var body: some View {
Text(stockDetailVM.stock.ticker)
}
}
This took me the better part of two weeks to solve and I wanted to post it here for anyone experiencing the same issue I had. I rebuilt a simple example to make things easier to understand. Here is what the app does.
Connects to a Firestore database and starts a SnapShootListener to collect Note objects.
Once collected it stores the Notes, in an array of Note objects.
This array of notes is built into a List of notes.
Each note progresses to a NoteDetailView.
What I expected to happen was that as a Note was updated on the server, I would see the note update in real-time in the List and in the DetailView. My problem was that I could only see live updates in the List.
The reason for this is because you cannot bind a data element from a ForEach loop. This means that any updates made on the server never make it to the DetailView.
To solve this I introduced EnvironmentObjects and created DetailViews by passing the index of the Note to be shown in the DetailView to the DetailView. This allowed me to access the correct Note in the DetailView. The code below shows how this was done. NOTE: I did not added a NoteDetailViewModel.swift to simplify things.
<YOUR_PROJECT_NAME>App.swift
#main
struct ToDoSwiftUITutorialApp: App {
// 1. An #StateObject was created for the NoteRepository connected to Firestore.
#StateObject private var noteRepository = NoteRepository()
// Firebase
init() {
FirebaseApp.configure()
Auth.auth().signInAnonymously()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(noteRepository) // noteRepository was added to ContentView as an EnvironmentObject.
}
}
}
NoteRepository.swift
class NoteRepository: ObservableObject {
let db = Firestore.firestore()
// Publishing the array of Note object received from Firestore.
#Published var notes = [Note]()
init() {
loadData()
}
func loadData() {
db.collection("notes").addSnapshotListener { (querySnapshot, error) in
if let querySnapshot = querySnapshot {
self.notes = querySnapshot.documents.compactMap { document in
do {
let x = try document.data(as: Note.self)
return x
}
catch {
print(error)
}
return nil
}
}
}
}
}
NoteListView.swift
struct NoteListView: View {
#ObservedObject var noteListViewModel = NoteListViewModel()
var body: some View {
NavigationView {
List {
ForEach(noteListViewModel.noteCellViewModels.indices, id: \.self) { index in
NavigationLink(destination: NoteDetailView(index: index).environmentObject(self.noteListViewModel)) {
NoteCellView(noteCellViewModel: noteListViewModel.noteCellViewModels[index])
} // NavigationLink
} // ForEach
}// List
.navigationBarTitle("Notes")
} // NavigationView
}
}
NoteListViewModel.swift
class NoteListViewModel: ObservableObject {
#Published var noteRepository = NoteRepository()
#Published var noteCellViewModels = [NoteCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
noteRepository.$notes.map { notes in
notes.map { note in
NoteCellViewModel(note: note)
}
}
.assign(to: \.noteCellViewModels, on: self)
.store(in: &cancellables)
}
}
NoteCellView.swift
struct NoteCellView: View {
#ObservedObject var noteCellViewModel: NoteCellViewModel
var body: some View {
Text(noteCellViewModel.note.note)
}
}
NoteCellViewModel.swift
class NoteCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var note: Note
private var cancellables = Set<AnyCancellable>()
init(note: Note) {
self.note = note
$note.compactMap { note in
note.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
}
NoteDetailView.swift
struct NoteDetailView: View {
#EnvironmentObject var noteRepository: NoteRepository
var index: Int
var body: some View {
Text(noteRepository.notes[index].note)
}
}
I just want to do some test like this ↓
Create one publisher from first view
Pass it to second view
Bind the publisher with some property in second view and try to show it on screen
The code is ↓ (First View)
struct ContentView: View {
let publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(for: URLRequest(url: URL(string: "https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8")!))
.map(\.data.description)
.replaceError(with: "Error!")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
var body: some View {
NavigationView {
List {
NavigationLink(destination: ResponseView(publisher: publisher)) {
Text("Hello, World!")
}
}.navigationBarTitle("Title", displayMode: .inline)
}
}
}
(Second View)
struct ResponseView: View {
let publisher: AnyPublisher<String, Never>
#State var content: String = ""
var body: some View {
HStack {
VStack {
Text(content)
.font(.system(size: 12))
.onAppear { _ = self.publisher.assign(to: \.content, on: self) }
Spacer()
}
Spacer()
}
}
}
But the code is not working. The request failed with message blow ↓
2020-11-11 11:08:04.657375+0800 PandaServiceDemo[83721:1275181] Task <6B53516E-5127-4C5E-AD5F-893F1AEE77E8>.<1> finished with error [-999] Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8}
Can someone tell me what happened and what is the right approach to do this?
The issue here is, the Subscription isn't stored anywhere. You have to store it in a AnyCancellable var and retain the subscription.
Use .print() operator whenever you are debugging combine related issues. I find it really useful.
The right approach is to extract the publisher and subscription into an ObservableObject and inject it into the View or use #StateObject
class DataProvider: ObservableObject {
#Published var content: String = ""
private var bag = Set<AnyCancellable>()
private let publisher: AnyPublisher<String, Never>
init() {
publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(for: URLRequest(url: URL(string: "https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8")!))
.map(\.data.description)
.print()
.replaceError(with: "Error!")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func loadData() {
publisher.assign(to: \.content, on: self).store(in: &bag)
}
}
struct ContentView: View {
#StateObject var dataProvider = DataProvider()
var body: some View {
NavigationView {
List {
NavigationLink(destination: ResponseView(dataProvider: dataProvider)) {
Text("Hello, World!")
}
}.navigationBarTitle("Title", displayMode: .inline)
}
}
}
struct ResponseView: View {
let dataProvider: DataProvider
var body: some View {
HStack {
VStack {
Text(dataProvider.content)
.font(.system(size: 12))
.onAppear {
self.dataProvider.loadData()
}
Spacer()
}
Spacer()
}
}
}
Please note that we have used #StateObject to make sure that DataProvider instance does not get destroyed when the view updates.
You need to store the subscription, otherwise it would be de-initialized and automatically cancelled.
Typically, this is done like this:
var cancellables: Set<AnyCancellable> = []
// ...
publisher
.sink {...}
.store(in: &cancellables)
So, you can create a #State property like the above, or you can use .onReceive:
let publisher: AnyPublisher<String, Never>
var body: some View {
HStack {
// ...
}
.onReceive(publisher) {
content = $0
}
}
You should be careful with the above approaches, since if ResponseView is ever re-initialized, it would get a copy of the publisher (most publishers are value-types), so it would start a new request.
To avoid that, add .share() to the publisher:
let publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(...)
//...
.share()
.eraseToAnyPublisher()
In terms of SwiftUI, you are doing something fundamentally wrong: creating the publisher from the View. This means a new publisher will be created every time ContentView is instantiated, and for all means and purposes this can happen a lot of times, SwiftUI makes no guarantees a View will be instantiated only once.
What you need to do is to extract the published into some object, which is either injected from upstream, or managed by SwiftUI, via #StateObject.
Well there is 2 way for this Job: way one is better
Way 1:
import SwiftUI
struct ContentView: View {
#State var urlForPublicsh: URL?
var body: some View {
VStack
{
Text(urlForPublicsh?.absoluteString ?? "nil")
.padding()
Button("Change the Publisher") {urlForPublicsh = URL(string: "https://stackoverflow.com")}
.padding()
SecondView(urlForPublicsh: $urlForPublicsh)
}
.onAppear()
{
urlForPublicsh = URL(string: "https://www.apple.com")
}
}
}
struct SecondView: View {
#Binding var urlForPublicsh: URL?
var body: some View {
Text(urlForPublicsh?.absoluteString ?? "nil")
.padding()
}
}
Way 2:
import SwiftUI
class UrlForPublicshModel: ObservableObject
{
static let shared = UrlForPublicshModel()
#Published var url: URL?
}
struct ContentView: View {
#StateObject var urlForPublicshModel = UrlForPublicshModel.shared
var body: some View {
VStack
{
Text(urlForPublicshModel.url?.absoluteString ?? "nil")
.padding()
Button("Change the Publisher") {urlForPublicshModel.url = URL(string: "https://stackoverflow.com")}
.padding()
SecondView()
}
.onAppear()
{
urlForPublicshModel.url = URL(string: "https://www.apple.com")
}
}
}
struct SecondView: View {
#ObservedObject var urlForPublicshModel = UrlForPublicshModel.shared
var body: some View {
Text(urlForPublicshModel.url?.absoluteString ?? "nil")
.padding()
}
}
Imagine I have a view with some mutable state, but that the state might need to be updated to reflect changes in another object (e.g. a ViewModel).
How can I implement that in SwiftUI?
I've tried the following, but can't get the view to reflect updates coming from the ViewModel:
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 1, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
private var bind: AnyCancellable?
init() {
viewText = viewModel.text
bind = viewModel
.$text
.print()
.assign(to: \.viewText, on: self)
}
var body: some View {
VStack {
TextField(titleKey: "editable text", text: $viewText)
Text(viewText)
Text(viewModel.text)
}
.onAppear {
self.viewModel.fetch()
}
}
}
The TextField and the first Text element get their content from ContentView.viewText, the second Text goes directly to the source: ViewModel.text.
As expected, the second Text shows "loading" and then "done". The first Text never changes from "idle".
If next screen recording looks like answering your question
it was recorded using next code
import SwiftUI
import Combine
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 3, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}.onReceive(viewModel.$text.filter({ (s) -> Bool in
s == "done"
})) { (txt) in
self.viewText = txt
}.onAppear {
self.viewModel.fetch()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here is possible approach (tested & works with Xcode 11.2 / iOS 13.2) - modified only ContentView:
struct ContentView: View {
#ObservedObject var viewModel = ViewModelX()
#State private var viewText = "idle"
init() {
_viewText = State<String>(initialValue: viewModel.text)
}
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}
.onReceive(viewModel.$text) { value in
self.viewText = value
}
.onAppear {
self.viewModel.fetch()
}
}
}
I'm currently playing with Combine and SwiftUI and have built a prototype app using the MVVM pattern. The app utilises a timer and the state of the button controlling this is bound (inelegantly) to the view model utilising a PassThroughSubject.
When the button is pressed, this should toggle the value of a state variable; the value of this is passed to the view model's subject (using .send) which should send a single event per button press. However, there appears to be recursion or something equally weird going on as multiple events are sent to the subject and a runtime crash results without the UI ever being updated.
It's all a bit puzzling and I'm not sure if this is a bug in Combine or I've missed something. Any pointers would be much appreciated. Code below - I know it's messy ;-) I've trimmed it down to what appears to be relevant but let me know if you need more.
View:
struct ControlPanelView : View {
#State private var isTimerRunning = false
#ObjectBinding var viewModel: ControlPanelViewModel
var body: some View {
HStack {
Text("Case ID") // replace with binding to viewmode
Spacer()
Text("00:00:00") // repalce with binding to viewmodel
Button(action: {
self.isTimerRunning.toggle()
self.viewModel.apply(.isTimerRunning(self.isTimerRunning))
print("Button press")
}) {
isTimerRunning ? Image(systemName: "stop") : Image(systemName: "play")
}
}
// .onAppear(perform: { self.viewModel.apply(.isTimerRunning(self.isTimerRunning)) })
.font(.title)
.padding(EdgeInsets(top: 0, leading: 32, bottom: 0, trailing: 32))
}
}
Viewmodel:
final class ControlPanelViewModel: BindableObject, UnidirectionalDataType {
typealias InputType = Input
typealias OutputType = Output
private let didChangeSubject = PassthroughSubject<Void, Never>()
private var cancellables: [AnyCancellable] = []
let didChange: AnyPublisher<Void, Never>
// MARK:- Input
...
private let isTimerRunningSubject = PassthroughSubject<Bool, Never>()
....
enum Input {
...
case isTimerRunning(Bool)
...
}
func apply(_ input: Input) {
switch input {
...
case .isTimerRunning(let state): isTimerRunningSubject.send(state)
...
}
}
// MARK:- Output
struct Output {
var isTimerRunning = false
var elapsedTime = TimeInterval(0)
var concernId = ""
}
private(set) var output = Output() {
didSet { didChangeSubject.send() }
}
// MARK:- Lifecycle
init(timerService: TimerService = TimerService()) {
self.timerService = timerService
didChange = didChangeSubject.eraseToAnyPublisher()
bindInput()
bindOutput()
}
private func bindInput() {
utilities.debugSubject(subject: isTimerRunningSubject)
let timerToggleStream = isTimerRunningSubject
.subscribe(isTimerRunningSubject)
...
cancellables += [
timerToggleStream,
elapsedTimeStream,
concernIdStream
]
}
private func bindOutput() {
let timerToggleStream = isTimerRunningSubject
.assign(to: \.output.isTimerRunning, on: self)
...
cancellables += [
timerToggleStream,
elapsedTimeStream,
idStream
]
}
}
In your bindInput method isTimerRunningSubject subscribes to itself. I suspect this is not what you intended and probably explains the weird recursion you are describing. Maybe you're missing a self. somewhere?
Also odd is that both bindInput and bindOutput add all streams to the cancellables array, so they'll be in there twice.
Hope this helps.
This example works as expected but I discovered during the process that you cannot use the pattern in the original code (internal Structs to define Input and Output) with #Published. This causes some fairly odd errors (and BAD_ACCESS in a Playground) and is a bug that was reported in Combine beta 3.
final class ViewModel: BindableObject {
var didChange = PassthroughSubject<Void, Never>()
#Published var isEnabled = false
private var cancelled = [AnyCancellable]()
init() {
bind()
}
private func bind() {
let t = $isEnabled
.map { _ in }
.eraseToAnyPublisher()
.subscribe(didChange)
cancelled += [t]
}
}
struct ContentView : View {
#ObjectBinding var viewModel = ViewModel()
var body: some View {
HStack {
viewModel.isEnabled ? Text("Button ENABLED") : Text("Button disabled")
Spacer()
Toggle(isOn: $viewModel.isEnabled, label: { Text("Enable") })
}
.padding()
}
}