2

I would like to reset some @State each time an @ObservedObject is "reassigned". How can this be accomplished?


class Selection: ObservableObject {
    let id = UUID()
}

struct ContentView: View {
  @State var selectedIndex: Int = 0
  var selections: [Selection] = [Selection(), Selection(), Selection()]
  var body: some View {
        VStack {
            // Assume the user can select something so selectedIndex changes all the time
            // SwiftUI will just keep updating the same presentation layer with new data
            Button("Next") {
                self.selectedIndex += 1
                if self.selectedIndex >= self.selections.count {
                    self.selectedIndex = 0
                }
            }
            SelectionDisplayer(selection: self.selections[self.selectedIndex])
        }
  }
}

struct SelectionDisplayer: View {
    @ObservedObject var selection: Selection {
        didSet { // Wish there were something *like* this
            print("will never happen")
            self.tabIndex = 0
        }
    }

  @State var tapCount: Int = 0 // Reset this to 0 when `selection` changes from one object to another

  var body: some View {
        Text(self.selection.id.description)
            .onReceive(self.selection.objectWillChange) {
                print("will never happen")
        }
        Button("Tap Count: \(self.tapCount)") {
            self.tapCount += 1
        }
    }
}

I'm aware of onReceive but I'm not looking to modify state in response to objectWillChange but rather when the object itself is switched out. In UIKit with reference types I would use didSet but that doesn't work here.

I did try using a PreferenceKey for this (gist) but it seems like too much of a hack.

arsenius
  • 12,090
  • 7
  • 58
  • 76

3 Answers3

3

Currently (Beta 5), the best way may be to use the constructor plus a generic ObservableObject for items that I want to reset when the data changes. This allows some @State to be preserved, while some is reset.

In the example below, tapCount is reset each time selection changes, while allTimeTaps is not.

class StateHolder<Value>: ObservableObject {
    @Published var value: Value
    init(value: Value) {
        self.value = value
    }
}

struct ContentView: View {

  @State var selectedIndex: Int = 0
  var selections: [Selection] = [Selection(), Selection(), Selection()]
  var body: some View {
        VStack {
            // Assume the user can select something so selectedIndex changes all the time
            // SwiftUI will just keep updating the same presentation though
            Button("Next") {
                self.selectedIndex += 1
                if self.selectedIndex >= self.selections.count {
                    self.selectedIndex = 0
                }
            }
            SelectionDisplayer(selection: self.selections[self.selectedIndex])
        }
  }
}

struct SelectionDisplayer: View {

    struct SelectionState {
        var tapCount: Int = 0
    }

    @ObservedObject var selection: Selection
    @ObservedObject var stateHolder: StateHolder<SelectionState>
    @State var allTimeTaps: Int = 0

    init(selection: Selection) {
        let state = SelectionState()
        self.stateHolder = StateHolder(value: state)
        self.selection = selection
    }

  var body: some View {
        VStack {
            Text(self.selection.id.description)
            Text("All Time Taps: \(self.allTimeTaps)")
            Text("Tap Count: \(self.stateHolder.value.tapCount)")
            Button("Tap") {
                self.stateHolder.value.tapCount += 1
                self.allTimeTaps += 1
            }
        }
    }
}

While looking for a solution I was quite interested to discover that you cannot initialize @State variables in init. The compiler will complain that all properties have not yet been set before accessing self.

arsenius
  • 12,090
  • 7
  • 58
  • 76
1

The only way to get this done for me was to force the parent view to redraw the child by temporarily hiding it. It's a hack, but the alternative was to pass a $tapCount in, which is worse since then the parent does not only have to know that it has to be redrawn, but must also know of state inside.

This can probably be refactored into it's own view, which make it not as dirty.

import Foundation
import SwiftUI
import Combine

class Selection {
    let id = UUID()
}

struct RedirectStateChangeView: View {
    @State var selectedIndex: Int = 0
    @State var isDisabled = false
    var selections: [Selection] = [Selection(), Selection(), Selection()]
    var body: some View {
        VStack {
            // Assume the user can select something so selectedIndex changes all the time
            // SwiftUI will just keep updating the same presentation layer with new data
            Button("Next") {
                self.selectedIndex += 1
                if self.selectedIndex >= self.selections.count {
                    self.selectedIndex = 0
                }
                self.isDisabled = true
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.005) {
                    self.isDisabled = false
                }
            }
            if !isDisabled {
                SelectionDisplayer(selection: selections[selectedIndex])
            }
        }
    }
}

struct SelectionDisplayer: View {
    var selection: Selection
    @State var tapCount: Int = 0 // Reset this to 0 when `selection` changes from one object to another

    var body: some View {
        VStack{
            Text(selection.id.description)
            Button("Tap Count: \(self.tapCount)") {
                self.tapCount += 1
            }
        }
    }
}
Fabian
  • 5,040
  • 2
  • 23
  • 35
  • Cool idea. Wish it didn't use `DispatchQueue` though. – arsenius Aug 09 '19 at 14:40
  • You can also create a one-off timer-publisher for that and set `isDisabled` inside `.onReceive(timer){}`. I did sadly not get it to redraw without a much smaller timeframe. – Fabian Aug 09 '19 at 15:02
0

This does what you want I believe:

class Selection: ObservableObject { let id = UUID() }

struct ContentView: View {
  @State var selectedIndex: Int = 0
  var selections: [Selection] = [Selection(), Selection(), Selection()]
  @State var count = 0

  var body: some View {
        VStack {
            Button("Next") {
                self.count = 0
                self.selectedIndex += 1
                if self.selectedIndex >= self.selections.count {
                    self.selectedIndex = 0
                }
            }
            SelectionDisplayer(selection: self.selections[self.selectedIndex], count: $count)
        }
  }
}

struct SelectionDisplayer: View {
    @ObservedObject var selection: Selection
    @Binding var count: Int

    var body: some View {
        VStack {
            Text("\(self.selection.id)")
            Button("Tap Count: \(self.count)") { self.count += 1 }
        }
    }
}

My Xcode didn't like your code so I needed to make a few other changes than just moving the count into the parent

Michael Salmon
  • 1,056
  • 7
  • 15