arsenius
arsenius

Reputation: 13246

How to modify/reset @State when other variables are updated in SwiftUI

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.

Upvotes: 2

Views: 8171

Answers (3)

arsenius
arsenius

Reputation: 13246

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.

Upvotes: 3

Michael Salmon
Michael Salmon

Reputation: 1184

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

Upvotes: 0

Fabian
Fabian

Reputation: 5348

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
            }
        }
    }
}

Upvotes: 1

Related Questions