In my SwiftUI view I have to trigger an action when a Toggle() changes its state. The toggle itself only takes a Binding.
I therefore tried to trigger the action in the didSet of the #State variable. But the didSet never gets called.
Is there any (other) way to trigger an action? Or any way to observe the value change of a #State variable?
My code looks like this:
struct PWSDetailView : View {
#ObjectBinding var station: PWS
#State var isDisplayed: Bool = false {
didSet {
if isDisplayed != station.isDisplayed {
PWSStore.shared.toggleIsDisplayed(station)
}
}
}
var body: some View {
VStack {
ZStack(alignment: .leading) {
Rectangle()
.frame(width: UIScreen.main.bounds.width, height: 50)
.foregroundColor(Color.lokalZeroBlue)
Text(station.displayName)
.font(.title)
.foregroundColor(Color.white)
.padding(.leading)
}
MapView(latitude: station.latitude, longitude: station.longitude, span: 0.05)
.frame(height: UIScreen.main.bounds.height / 3)
.padding(.top, -8)
Form {
Toggle(isOn: $isDisplayed)
{ Text("Wetterstation anzeigen") }
}
Spacer()
}.colorScheme(.dark)
}
}
The desired behaviour would be that the action "PWSStore.shared.toggleIsDisplayed(station)" is triggered when the Toggle() changes its state.
iOS 14+
If you're using iOS 14 and higher you can use onChange:
struct ContentView: View {
#State private var isDisplayed = false
var body: some View {
Toggle("", isOn: $isDisplayed)
.onChange(of: isDisplayed) { value in
// action...
print(value)
}
}
}
Here is a version without using tapGesture.
#State private var isDisplayed = false
Toggle("", isOn: $isDisplayed)
.onReceive([self.isDisplayed].publisher.first()) { (value) in
print("New value is: \(value)")
}
iOS13+
Here is a more generic approach you can apply to any Binding for almost all built in Views like Pickers, Textfields, Toggle..
extension Binding {
func didSet(execute: #escaping (Value) -> Void) -> Binding {
return Binding(
get: { self.wrappedValue },
set: {
self.wrappedValue = $0
execute($0)
}
)
}
}
And usage is simply;
#State var isOn: Bool = false
Toggle("Title", isOn: $isOn.didSet { (state) in
print(state)
})
iOS14+
#State private var isOn = false
var body: some View {
Toggle("Title", isOn: $isOn)
.onChange(of: isOn) { _isOn in
/// use _isOn here..
}
}
The cleanest approach in my opinion is to use a custom binding.
With that you have full control when the toggle should actually switch
import SwiftUI
struct ToggleDemo: View {
#State private var isToggled = false
var body: some View {
let binding = Binding(
get: { self.isToggled },
set: {
potentialAsyncFunction($0)
}
)
func potentialAsyncFunction(_ newState: Bool) {
//something async
self.isToggled = newState
}
return Toggle("My state", isOn: binding)
}
}
I think it's ok
struct ToggleModel {
var isWifiOpen: Bool = true {
willSet {
print("wifi status will change")
}
}
}
struct ToggleDemo: View {
#State var model = ToggleModel()
var body: some View {
Toggle(isOn: $model.isWifiOpen) {
HStack {
Image(systemName: "wifi")
Text("wifi")
}
}.accentColor(.pink)
.padding()
}
}
I found a simpler solution, just use onTapGesture:D
Toggle(isOn: $stateChange) {
Text("...")
}
.onTapGesture {
// Any actions here.
}
This is how I code:
Toggle("Title", isOn: $isDisplayed)
.onReceive([self.isDisplayed].publisher.first()) { (value) in
//Action code here
}
Updated code (Xcode 12, iOS14):
Toggle("Enabled", isOn: $isDisplayed.didSet { val in
//Action here
})
The .init is the constructor of Binding
#State var isDisplayed: Bool
Toggle("some text", isOn: .init(
get: { isDisplayed },
set: {
isDisplayed = $0
print("changed")
}
))
Based on #Legolas Wang's answer.
When you hide the original label from the toggle you can attach the tapGesture only to the toggle itself
HStack {
Text("...")
Spacer()
Toggle("", isOn: $stateChange)
.labelsHidden()
.onTapGesture {
// Any actions here.
}
}
class PWSStore : ObservableObject {
...
var station: PWS
#Published var isDisplayed = true {
willSet {
PWSStore.shared.toggleIsDisplayed(self.station)
}
}
}
struct PWSDetailView : View {
#ObservedObject var station = PWSStore.shared
...
var body: some View {
...
Toggle(isOn: $isDisplayed) { Text("Wetterstation anzeigen") }
...
}
}
Demo here https://youtu.be/N8pL7uTjEFM
Here's my approach. I was facing the same issue, but instead decided to wrap UIKit's UISwitch into a new class conforming to UIViewRepresentable.
import SwiftUI
final class UIToggle: UIViewRepresentable {
#Binding var isOn: Bool
var changedAction: (Bool) -> Void
init(isOn: Binding<Bool>, changedAction: #escaping (Bool) -> Void) {
self._isOn = isOn
self.changedAction = changedAction
}
func makeUIView(context: Context) -> UISwitch {
let uiSwitch = UISwitch()
return uiSwitch
}
func updateUIView(_ uiView: UISwitch, context: Context) {
uiView.isOn = isOn
uiView.addTarget(self, action: #selector(switchHasChanged(_:)), for: .valueChanged)
}
#objc func switchHasChanged(_ sender: UISwitch) {
self.isOn = sender.isOn
changedAction(sender.isOn)
}
}
And then its used like this:
struct PWSDetailView : View {
#State var isDisplayed: Bool = false
#ObservedObject var station: PWS
...
var body: some View {
...
UIToggle(isOn: $isDisplayed) { isOn in
//Do something here with the bool if you want
//or use "_ in" instead, e.g.
if isOn != station.isDisplayed {
PWSStore.shared.toggleIsDisplayed(station)
}
}
...
}
}
First, do you actually know that the extra KVO notifications for station.isDisplayed are a problem? Are you experiencing performance problems? If not, then don't worry about it.
If you are experiencing performance problems and you've established that they're due to excessive station.isDisplayed KVO notifications, then the next thing to try is eliminating unneeded KVO notifications. You do that by switching to manual KVO notifications.
Add this method to station's class definition:
#objc class var automaticallyNotifiesObserversOfIsDisplayed: Bool { return false }
And use Swift's willSet and didSet observers to manually notify KVO observers, but only if the value is changing:
#objc dynamic var isDisplayed = false {
willSet {
if isDisplayed != newValue { willChangeValue(for: \.isDisplayed) }
}
didSet {
if isDisplayed != oldValue { didChangeValue(for: \.isDisplayed) }
}
}
You can try this(it's a workaround):
#State var isChecked: Bool = true
#State var index: Int = 0
Toggle(isOn: self.$isChecked) {
Text("This is a Switch")
if (self.isChecked) {
Text("\(self.toggleAction(state: "Checked", index: index))")
} else {
CustomAlertView()
Text("\(self.toggleAction(state: "Unchecked", index: index))")
}
}
And below it, create a function like this:
func toggleAction(state: String, index: Int) -> String {
print("The switch no. \(index) is \(state)")
return ""
}
Here is a handy extension I wrote to fire a callback whenever the toggle is pressed. Unlike a lot of the other solutions this truly only will fire when the toggle is switched and not on init which for my use case was important. This mimics similar SwiftUI initializers such as TextField for onCommit.
USAGE:
Toggle("My Toggle", isOn: $isOn, onToggled: { value in
print(value)
})
EXTENSIONS:
extension Binding {
func didSet(execute: #escaping (Value) -> Void) -> Binding {
Binding(
get: { self.wrappedValue },
set: {
self.wrappedValue = $0
execute($0)
}
)
}
}
extension Toggle where Label == Text {
/// Creates a toggle that generates its label from a localized string key.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// `Text` for more information about localizing strings.
///
/// To initialize a toggle with a string variable, use
/// ``Toggle/init(_:isOn:)-2qurm`` instead.
///
/// - Parameters:
/// - titleKey: The key for the toggle's localized title, that describes
/// the purpose of the toggle.
/// - isOn: A binding to a property that indicates whether the toggle is
/// on or off.
/// - onToggled: A closure that is called whenver the toggle is switched.
/// Will not be called on init.
public init(_ titleKey: LocalizedStringKey, isOn: Binding<Bool>, onToggled: #escaping (Bool) -> Void) {
self.init(titleKey, isOn: isOn.didSet(execute: { value in onToggled(value) }))
}
/// Creates a toggle that generates its label from a string.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// title similar to ``Text/init(_:)-9d1g4``. See `Text` for more
/// information about localizing strings.
///
/// To initialize a toggle with a localized string key, use
/// ``Toggle/init(_:isOn:)-8qx3l`` instead.
///
/// - Parameters:
/// - title: A string that describes the purpose of the toggle.
/// - isOn: A binding to a property that indicates whether the toggle is
/// on or off.
/// - onToggled: A closure that is called whenver the toggle is switched.
/// Will not be called on init.
public init<S>(_ title: S, isOn: Binding<Bool>, onToggled: #escaping (Bool) -> Void) where S: StringProtocol {
self.init(title, isOn: isOn.didSet(execute: { value in onToggled(value) }))
}
}
Available for Xcode 13.4
import SwiftUI
struct ToggleBootCamp: View {
#State var isOn: Bool = true
#State var status: String = "ON"
var body: some View {
NavigationView {
VStack {
Toggle("Switch", isOn: $isOn)
.onChange(of: isOn, perform: {
_isOn in
// Your code here...
status = _isOn ? "ON" : "OFF"
})
Spacer()
}.padding()
.navigationTitle("Toggle switch is: \(status)")
}
}
}
Just in case you don't want to use extra functions, mess the structure - use states and use it wherever you want. I know it's not a 100% answer for the event trigger, however, the state will be saved and used in the most simple way.
struct PWSDetailView : View {
#State private var isToggle1 = false
#State private var isToggle2 = false
var body: some View {
ZStack{
List {
Button(action: {
print("\(self.isToggle1)")
print("\(self.isToggle2)")
}){
Text("Settings")
.padding(10)
}
HStack {
Toggle(isOn: $isToggle1){
Text("Music")
}
}
HStack {
Toggle(isOn: $isToggle1){
Text("Music")
}
}
}
}
}
}
lower than iOS 14:
extension for Binding with Equatable check
public extension Binding {
func onChange(_ handler: #escaping (Value) -> Void) -> Binding<Value> where Value: Equatable {
Binding(
get: { self.wrappedValue },
set: { newValue in
if self.wrappedValue != newValue { // equal check
self.wrappedValue = newValue
handler(newValue)
}
}
)
}
}
Usage:
Toggle(isOn: $pin.onChange(pinChanged(_:))) {
Text("Equatable Value")
}
func pinChanged(_ pin: Bool) {
}
Add a transparent Rectangle on top, then:
ZStack{
Toggle(isOn: self.$isSelected, label: {})
Rectangle().fill(Color.white.opacity(0.1))
}
.contentShape(Rectangle())
.onTapGesture(perform: {
self.isSelected.toggle()
})
Available for XCode 12
import SwiftUI
struct ToggleView: View {
#State var isActive: Bool = false
var body: some View {
Toggle(isOn: $isActive) { Text(isActive ? "Active" : "InActive") }
.padding()
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
}
Related
I'm trying to create a modal popup system working similarly to .fullScreenCover, looking something like this:
My requirements are:
Have custom transition and presentation style (therefore I can't use .fullScreenCover)
Be able to present modal from child components
Here's a functional code snippet that satisfies those two conditions, you can run it:
struct Screen: View {
#StateObject private var model = Model()
var body: some View {
Navigation {
VStack {
Text("model.number: \(model.number)").opacity(0.5)
ChildComponent(number: $model.number)
Spacer()
}
.padding(.vertical, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.purple.opacity(0.4))
}
}
}
struct ChildComponent: View {
#EnvironmentObject var navigator: Navigator
#Binding var number: Int
#State private var isFullScreenPresented = false
var body: some View {
VStack(spacing: 20) {
Text("\(number)").bold()
Button("Change (custom)", action: presentCustom).foregroundColor(.black)
Button("Change (full screen)", action: presentFullScreen).foregroundColor(.black)
}
.padding(30)
.background(Color.black.opacity(0.1))
.modalBottom(id: "childModal") {
NumberModalView(number: $number)
}
.fullScreenCover(isPresented: $isFullScreenPresented) {
NumberModalView(number: $number).environment(\.dismissModal, { isFullScreenPresented = false })
}
}
func presentCustom() {
navigator.presentModalBottom(id: "childModal")
}
func presentFullScreen() {
isFullScreenPresented = true
}
}
struct ModalView<Content:View>: View {
#Environment(\.dismissModal) var dismissCallback
#ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: 30) {
Button("Dismiss", action: { dismissCallback() }).foregroundColor(.black)
content()
}
.padding(30)
.frame(maxWidth: .infinity)
.background(Color.purple.opacity(0.8))
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
struct NumberModalView: View {
#Binding var number: Int
var body: some View {
ModalView {
HStack(spacing: 20) {
Button(action: { number -= 1 }) { Image(systemName: "minus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
Text("\(number)").bold()
Button(action: { number += 1 }) { Image(systemName: "plus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
}
}
}
}
// MARK: - Navigation
struct Navigation<Content:View>: View {
#ViewBuilder var content: () -> Content
#StateObject private var navigator = Navigator()
#State private var modalPresentations: [String:ModalData] = [:]
var body: some View {
ZStack {
content()
if let modalID = navigator.currentModalBottom, let modal = modalPresentations[modalID] {
modal.content().environment(\.dismissModal, navigator.dismissModalBottom)
}
}
.environmentObject(navigator)
.onPreferenceChange(ModalPresentationKey.self) { modalPresentations in
self.modalPresentations = modalPresentations
}
}
}
// MARK: - Model
class Model: ObservableObject {
#Published var number: Int = 0
}
struct ModalData: Hashable {
var id: String
var content: () -> AnyView
static func == (lhs: ModalData, rhs: ModalData) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
class Navigator: ObservableObject {
#Published var currentModalBottom: String?
func presentModalBottom(id: String) {
currentModalBottom = id
}
func dismissModalBottom() {
currentModalBottom = nil
}
}
// MARK: - Dismiss (Environment key)
private struct ModalDismissKey: EnvironmentKey {
static let defaultValue: () -> Void = {}
}
extension EnvironmentValues {
var dismissModal: () -> Void {
get { self[ModalDismissKey.self] }
set { self[ModalDismissKey.self] = newValue }
}
}
// MARK: - Present (Preference key)
struct ModalPresentationKey: PreferenceKey {
static var defaultValue: [String:ModalData] = [:]
static func reduce(value: inout [String:ModalData], nextValue: () -> [String:ModalData]) {
for (k,v) in nextValue() { value[k] = v }
}
}
extension View {
func modalBottom<V:View>(id: String, #ViewBuilder content: #escaping () -> V) -> some View {
preference(key: ModalPresentationKey.self, value: [
id: ModalData(id: id, content: { AnyView(content()) })
])
}
}
// MARK: - Preview
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Screen()
}
}
}
Now the problem: while the parent view value gets updated (values at the top), the modal view value is not updated (text box between the stepper at the bottom). If you try with the default full screen, you'll see that it works normally.
I'm guessing it's a problem with data flow and the fact that the modal is not a child of the component.
Since I've already spent weeks on this problem, here are some surprising things I found:
If you replace the #StateObject model with a simple #State var of type Int in Screen, it works (?!). In my case, I have a complex model which I can't replace with simple state variables.
If you add a dependency to the navigator in NumberModalView, by adding #Environment(\.dismissModal) var dismissCallback, it works. This seems crazy to me, I don't see what role the navigator is playing in the modal data flow.
How to make the modal view react to model changes while keeping my requirements above?
I already talked about this problem here and here, but at the time I was bridging with UIKit and I thought the problem came from that, but it doesn't.
I want the user can only click the toggle under some specific condition. So I reverse the value of "AutocorrectStatus" again under .onChange method. But it seems like the view doesn't follow this change. It still becomes on from off even the value of AutocorrectStatus is false. What should I do?
class GlobalEnvironment: ObservableObject {
#Published var AutocorrectStatus = false
}
struct SettingView: View {
#EnvironmentObject var env: GlobalEnvironment
HStack() {
Toggle("", isOn: self.$env.AutocorrectStatus)
.labelsHidden()
.onChange(of: self.env.AutocorrectStatus) { _AutocorrectStatus in
self.env.AutocorrectStatus = !self.env.AutocorrectStatus
}
if self.env.AutocorrectStatus {
Text ("ON")
.font(.system(size: 26, weight: .semibold))
.frame(alignment: .topLeading)
} else {
Text ("OFF")
.font(.system(size: 26, weight: .semibold))
.frame(alignment: .topLeading)
}
}
}
There are several ways to do this. One way is to provide the toggle a Binding that performs the necessary checks before updating the value.
var body: some View {
HStack {
Toggle("", isOn: self.provideAutocorrectBinding())
}
}
func provideAutocorrectBinding() -> Binding<Bool> {
return Binding(get: {
return self.env.AutocorrectStatus
}, set: { newValue in
let isConnected = false // Your logic to check the connection
if isConnected {
self.env.AutocorrectStatus = newValue
}
})
}
You can trigger the alert there as well:
struct ContentView: View {
#EnvironmentObject var env: GlobalEnvironment
#State private var showingAlert = false
var body: some View {
HStack {
Toggle("", isOn: self.provideAutocorrectBinding())
}
.alert("Your message.", isPresented: $showingAlert) {
Button("OK", role: .cancel) { }
}
}
func provideAutocorrectBinding() -> Binding<Bool> {
return Binding(get: {
return self.env.AutocorrectStatus
}, set: { newValue in
let isConnected = false // Your logic to check the connection
if isConnected {
self.env.AutocorrectStatus = newValue
} else {
self.showingAlert.toggle()
}
})
}
}
I am making a view in SwiftUI with a picker-popover. When picking a value and dismissing the view everything works fine.
But I need to be able to dismiss the picker WITHOUT setting the newly selected value, and have it go back to the initial value it had when being opened.
You can see the code here:
import SwiftUI
struct ContentView: View {
#State var showPicker = false
#State var selectedPickerOption = 0
let pickerOptions = ["Hello", "World", "Yes"]
var body: some View {
ZStack {
VStack {
Text("Selected Option: \(pickerOptions[selectedPickerOption])")
Button(
action: {
showPicker = true
},
label: {
Text("Open Picker")
.padding()
}
)
}
if showPicker {
PickerPopover(
pickerOptions: pickerOptions,
width: 300,
height: 300,
showPicker: $showPicker,
selectedPickerOption: $selectedPickerOption,
initialPickerOption: selectedPickerOption
)
.background(Color.red)
}
}
}
}
struct PickerPopover: View {
var pickerOptions: [String]
var width: CGFloat
var height: CGFloat
#Binding var showPicker: Bool
#Binding var selectedPickerOption: Int
var initialPickerOption: Int // This one doesn't work yet
func selectOption() {
withAnimation {
showPicker.toggle()
}
}
func cancel() {
// ######### THIS LINE HERE ISN'T WORKING ##############
selectedPickerOption = initialPickerOption
withAnimation {
showPicker.toggle()
}
}
var body: some View {
VStack {
Picker(
selection: $selectedPickerOption,
label: Text("")
) {
ForEach(0 ..< pickerOptions.count) {
Text(self.pickerOptions[$0])
}
}
.pickerStyle(WheelPickerStyle())
Button(action: cancel) {
Text("Cancel")
}
Button(action: selectOption) {
Text("Select")
}
}
.transition(.move(edge: .bottom))
}
}
I believe the first line in the cancel() function should do the trick - if selectedPickerOption is set to 0 (or 1, etc) that will reset the picker to that index specifically.
I have been unable to set it dynamically though. I have tried passing in an additional value (intialPickerOption), but resetting selectedPickerOption = initialPickerOption does seem to set it to the actual currently selected selectedPickerOption, and the picker behaves as if that was chosen correctly.
What am I possibly missing here?
The problem occurs as you are modifying selectedPickerOption which will cause your ContentView to reload whenever the picker changes. Hence, you will pass the selected value as initialPickerOption. selectedPickerOption will always be the same like your initial value.
Here is a solution with using local State in your PickerView and then sync the Binding on Select or don't sync it. I comment the code at these parts
struct PickerPopover: View {
var pickerOptions: [String]
var width: CGFloat
var height: CGFloat
#Binding var showPicker: Bool
#Binding var selectedPickerOption: Int
#State var localState : Int = 0 //<< Here your local State
func selectOption() {
self.selectedPickerOption = localState //<< Sync the binding with the local State
withAnimation {
showPicker.toggle()
}
}
func cancel() {
//<< do nothing here
withAnimation {
showPicker.toggle()
}
}
var body: some View {
VStack {
Picker(
selection: $localState,
label: Text("")
) {
ForEach(0 ..< pickerOptions.count) {
Text(self.pickerOptions[$0])
}
}
.pickerStyle(WheelPickerStyle())
Button(action: cancel) {
Text("Cancel")
}
Button(action: selectOption) {
Text("Select")
}
}
.transition(.move(edge: .bottom))
.onAppear {
self.localState = selectedPickerOption // << set inital value here
}
}
}
I would like to run a function each time a tab is tapped.
On the code below (by using onTapGesture) when I tap on a new tab, myFunction is called, but the tabview is not changed.
struct DetailView: View {
var model: MyModel
#State var selectedTab = 1
var body: some View {
TabView(selection: $selectedTab) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
.onTapGesture {
model.myFunction(item: selectedTab)
}
}
}
How can I get both things:
the tabview being normally displayed
my function being called
As of iOS 14 you can use onChange to execute code when a state variable changes. You can replace your tap gesture with this:
.onChange(of: selectedTab) { newValue in
model.myFunction(item: newValue)
}
If you don't want to be restricted to iOS 14 you can find additional options here: How can I run an action when a state changes?
The above answers work well except in one condition. If you are present in the same tab .onChange() won't be called. the better way is by creating an extension to binding
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}
}
the usage will be like this
TabView(selection: $selectedTab.onUpdate{ model.myFunction(item: selectedTab) }) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel:
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}
I do not why but I have very frustrating bug in my SwiftUI view.
This view has reference to ViewModel object. But this View is created multiple times on screen appear, and at the end the single View have multiple references to ViewModel object.
I reference this view model object in custom Binding setter/getter or in closure. But object references in Binding and in closure are totally different. This causes many problems with proper View refreshing or saving changes.
struct DealDetailsStagePicker : View {
// MARK: - Observed
#ObservedObject var viewModel: DealDetailsStageViewModel
// MARK: - State
/// TODO: It is workaround as viewModel.dealStageId doesn't work correctly
/// viewModel object is instantiated several times and pickerBinding and onDone
/// closure has different references to viewModel object
/// so updating dealStageId via pickerBinding refreshes it in different viewModel
/// instance than onDone closure executed changeDealStage() method (where dealStageId
/// property stays with initial or nil value.
#State var dealStageId: String? = nil
// MARK: - Binding
#Binding private var showPicker: Bool
// MARK: - Properties
let deal : Deal
// MARK: - Init
init(deal: Deal, showPicker: Binding<Bool>) {
self.deal = deal
self._showPicker = showPicker
self.viewModel = DealDetailsStageViewModel(dealId: deal.id!)
}
var body: some View {
let pickerBinding = Binding<String>(get: {
if self.viewModel.dealStageId == nil {
self.viewModel.dealStageId = self.dealStage?.id ?? ""
}
return self.viewModel.dealStageId!
}, set: { id in
self.viewModel.dealStageId = id //THIS viewModel is reference to object 0x8784783
self.dealStageId = id
})
return VStack(alignment: .leading, spacing: 4) {
Text("Stage".uppercased())
Button(action: {
self.showPicker = true
}) {
HStack {
Text("\(deal.status ?? "")")
Image(systemName: "chevron.down")
}
.contentShape(Rectangle())
}
}
.buttonStyle(BorderlessButtonStyle())
.adaptivePicker(isPresented: $showPicker, selection: pickerBinding, popoverSize: CGSize(width: 400, height: 200), popoverArrowDirection: .up, onDone: {
// save change
self.viewModel.changeDealStage(self.dealStages, self.dealStageId) // THIS viewModel references 0x92392983
}) {
ForEach(self.dealStages, id: \.id) { stage in
Text(stage.name)
.foregroundColor(Color("Black"))
}
}
}
}
I am experiencing this problem in multiple places writing SwiftUI code.
I have several workarounds:
1) as you can see here I use additional #State var to store dealStageId and pass it to viewModale.changeDealStage() instead of updating it on viewModal
2) in other places I am using Wrapper View around such view, then add #State var viewModel: SomeViewModel, then pass this viewModel and assign to #ObservedObject.
But this errors happens randomly depending on placement this View as Subview of other Views. Sometimes it works, sometime it does not work.
It is very od that SINGLE view can have references to multiple view models even if it is instantiated multiple times.
Maybe the problem is with closure as it keeps reference to first ViewModel instance and then this closure is not refreshed in adaptivePicker view modifier?
Workarounds around this issue needs many debugging and boilerplate code to write!
Anyone can help what I am doing wrong or what is wrong with SwiftUI/ObservableObject?
UPDATE
Here is the usage of this View:
private func makeDealHeader() -> some View {
VStack(spacing: 10) {
Spacer()
VStack(spacing: 4) {
Text(self.deal?.name ?? "")
Text(NumberFormatter.price.string(from: NSNumber(value: Double(self.deal?.amount ?? 0)/100.0))!)
}.frame(width: UIScreen.main.bounds.width*0.667)
HStack {
if deal != nil {
DealDetailsStagePicker(deal: self.deal!, showPicker: self.$showStagePicker)
}
Spacer(minLength: 24)
if deal != nil {
DealDetailsClientPicker(deal: self.deal!, showPicker: self.$showClientPicker)
}
}
.padding(.horizontal, 24)
self.makeDealIcons()
Spacer()
}
.frame(maxWidth: .infinity)
.listRowInsets(EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0))
}
var body: some View {
ZStack {
Color("White").edgesIgnoringSafeArea(.all)
VStack {
self.makeNavigationLink()
List {
self.makeDealHeader()
Section(header: self.makeSegmentedControl()) {
self.makeSection()
}
}
....
UPDATE 2
Here is adaptivePicker
extension View {
func adaptivePicker<Data, ID, Content>(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, Content>) -> some View where Data : RandomAccessCollection, ID: Hashable, Content: View {
self.modifier(AdaptivePicker2(isPresented: isPresented, selection: selection, popoverSize: popoverSize, popoverArrowDirection: popoverArrowDirection, onDone: onDone, content: content))
}
and here is AdaptivePicker2 view modifier implementation
struct AdaptivePicker2<Data, ID, RowContent> : ViewModifier, OrientationAdjustable where Data : RandomAccessCollection, ID: Hashable , RowContent: View {
// MARK: - Environment
#Environment(\.verticalSizeClass) var _verticalSizeClass
var verticalSizeClass: UserInterfaceSizeClass? {
_verticalSizeClass
}
// MARK: - Binding
private var isPresented: Binding<Bool>
private var selection: Binding<ID>
// MARK: - State
#State private var showPicker : Bool = false
// MARK: - Actions
private let onDone: (() -> Void)?
// MARK: - Properties
private let popoverSize: CGSize?
private let popoverArrowDirection: UIPopoverArrowDirection
private let pickerContent: () -> ForEach<Data, ID, RowContent>
// MARK: - Init
init(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, RowContent>) {
self.isPresented = isPresented
self.selection = selection
self.popoverSize = popoverSize
self.popoverArrowDirection = popoverArrowDirection
self.onDone = onDone
self.pickerContent = content
}
var pickerView: some View {
Picker("Select State", selection: self.selection) {
self.pickerContent()
}
.pickerStyle(WheelPickerStyle())
.labelsHidden()
}
func body(content: Content) -> some View {
let isShowingBinding = Binding<Bool>(get: {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation {
self.showPicker = self.isPresented.wrappedValue
}
}
return self.isPresented.wrappedValue
}, set: {
self.isPresented.wrappedValue = $0
})
let popoverBinding = Binding<Bool>(get: {
self.isPresented.wrappedValue
}, set: {
self.onDone?()
self.isPresented.wrappedValue = $0
})
return Group {
if DeviceType.IS_ANY_IPAD {
if self.popoverSize != nil {
content.presentPopover(isShowing: popoverBinding, popoverSize: popoverSize, arrowDirection: popoverArrowDirection) { self.pickerView }
} else {
content.popover(isPresented: popoverBinding) { self.pickerView }
}
} else {
content.present(isShowing: isShowingBinding) {
ZStack {
Color("Dim")
.opacity(0.25)
.transition(.opacity)
.onTapGesture {
self.isPresented.wrappedValue = false
self.onDone?()
}
VStack {
Spacer()
// TEST: Text("Show Picker: \(self.showPicker ? "True" : "False")")
if self.showPicker {
VStack {
Divider().background(Color.white)
.shadow(color: Color("Dim"), radius: 4)
HStack {
Spacer()
Button("Done") {
print("Tapped picker done button!")
self.isPresented.wrappedValue = false
self.onDone?()
}
.foregroundColor(Color("Accent"))
.padding(.trailing, 16)
}
self.pickerView
.frame(height: self.isLandscape ? 120 : nil)
}
.background(Color.white)
.transition(.move(edge: .bottom))
.animation(.easeInOut(duration: 0.35))
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
}
}
}
It seems new #StateObject from iOS 14 will solve this issue in SwiftUI.