Intro
Takes this simple view as an example.
@State private var isOn: Bool = false
@State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
A simple VStack displays a Button that presents a Sheet, and a Toggle that modifies a local property. VStack has a Sheet modifier applied to it, that simply displays property modified by the Toggle.
Sounds simple, but there are issues in certain conditions.
Different App Runs
App Run 1 (No Bug):
- Don't press
Toggle(set to false) - Open
Sheet Textshows "false" and console logs "false"
App Run 2 (Bug):
- Press
Toggle(true) - Open
Sheet Textshows "false" and console logs "true"
App Run 3 (No Bug):
- Press
Toggle(true) - Open
Sheet - Close
Sheet - Press
Toggle(false) - Press
Toggle(true) - Open
Sheet Textshows "true" and console logs "true"
In the second run, Text in Sheet displays "false", while console logs "true". But closing the sheet and re-toggling the Toggle fixes the issue.
Also, console logs the following warning:
invalid mode 'kCFRunLoopCommonModes' provided to CFRunLoopRunSpecific - break on _CFRunLoopError_RunCalledWithInvalidMode to debug. This message will only appear once per execution.
Weird Fix
Adding same Text inside the VStack as well seems to fix the issue.
@State private var isOn: Bool = false
@State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
Text(String(isOn)) // <--
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
Problem can also be fixed by using onChange modifier.
@State private var isOn: Bool = false
@State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
.onChange(of: isOn, perform: { _ in }) // <--
}
Other UI Components
I have two custom made Toggle and BottomSheet components that are build in SwiftUI from scratch. I have also used them in the test.
Using native Toggle with my BottomSheet causes problem.
Using my Toggle with native Sheet DOESN'T cause problem.
Using my Toggle with my Sheet DOESN'T cause problem.
Swapping out native Toggle with native Button also causes the same issue:
@State private var isOn: Bool = false
@State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Button("Toggle", action: { isOn.toggle() }) // <--
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
Update
As suggested in the comments, using Sheet init with Binding item seems so solve the issue:
private struct Sheet: Identifiable {
let id: UUID = .init()
let isOn: Bool
}
@State private var presentedSheet: Sheet?
@State private var isOn: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { presentedSheet = .init(isOn: isOn) })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(item: $presentedSheet, content: { sheet in
Text(String(sheet.isOn))
})
}
However, as other older threads suggested, this may be a bug in SwiftUI, introduced in 2.0.
Another way of fixing the issue that doesn't require creating a new object and doing additional bookkeeping is just leaving an empty onChange modifier: .onChange(of: isOn, perform: { _ in }).
extension View {
func bindToModalContext<V>(
_ value: V
) -> some View
where V : Equatable
{
self
.onChange(of: value, perform: { _ in })
}
}
Other threads:
SwiftUI @State and .sheet() ios13 vs ios14
https://www.reddit.com/r/SwiftUI/comments/l744cb/running_into_state_issues_using_sheets/
.sheet()initializer for this. You wantsheet(item:onDismiss:content:). It allows you to establish aBindingconnection between the view and the sheet.onChangemodifier fix the problem? Unlike what you wrote,onChangemodifier is separate and doesn't establish any back-forth connection between view andSheet, but still seems to work just as fine.isPresentedinitializer doesn't capture the value;itemdoes.