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
Text
shows "false" and console logs "false"
App Run 2 (Bug):
- Press
Toggle
(true) - Open
Sheet
Text
shows "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
Text
shows "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/