2

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):

  1. Don't press Toggle (set to false)
  2. Open Sheet
  3. Text shows "false" and console logs "false"

App Run 2 (Bug):

  1. Press Toggle (true)
  2. Open Sheet
  3. Text shows "false" and console logs "true"

App Run 3 (No Bug):

  1. Press Toggle (true)
  2. Open Sheet
  3. Close Sheet
  4. Press Toggle (false)
  5. Press Toggle (true)
  6. Open Sheet
  7. 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/

https://developer.apple.com/forums/thread/661777

https://developer.apple.com/forums/thread/659660

Vakho
  • 87
  • 7
  • You are using the wrong `.sheet()` initializer for this. You want `sheet(item:onDismiss:content:)`. It allows you to establish a `Binding` connection between the view and the sheet. – Yrb May 20 '22 at 16:06
  • Your suggestion seems to solve the issue. I have edited the post, however this is definitely a bug in SwiftUI. Otherwise, why would writing an empty `onChange` modifier fix the problem? Unlike what you wrote, `onChange` modifier is separate and doesn't establish any back-forth connection between view and `Sheet `, but still seems to work just as fine. – Vakho May 20 '22 at 16:33
  • This is actually not a bug. The `isPresented` initializer doesn't capture the value; `item` does. – Yrb May 20 '22 at 16:41
  • Okay. I got the impression that it was a bug since threads form 1-2 years ago mentioned that this "issue" was introduced with iOS 14.0. – Vakho May 20 '22 at 16:46

1 Answers1

0

The sample code demonstrating the issue can be simplified to this:

struct SheetTestView: View {
    @State private var isPresented: Bool = false
    
    var body: some View {
        Button("Present") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented.description)
        }
    }
}

The thing you have to understand first is part of SwiftUI's magic is dependency tracking. Where body is only called if it actually uses a var that changes. Unfortunately that behavior does not extend to the code within blocks passed to view modifiers like sheet. So in the code above, SwiftUI is asking itself does any of the Views inside body display the value of isPresented? And that answer is no, so when the value of isPresented is changed, it does not need to call body and it doesn't.

So when body is called the first time, the block that is created for the sheet is using the original value of isPresented which is false. When the Button is pressed and isPresented is set to true, the block is called immediately and thus it still is using the value false.

Now you understand what is happening, a workaround is to make body actually use the value of isPresented, e.g.

struct SheetTestView: View {
    @State private var isPresented: Bool = false
    
    var body: some View {
        Button("Present (isPresented: \(isPresented))") { // now body has a dependency on it
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            Text(isPresented.description)
        }
    }
}

Now SwiftUI, does detect a dependency for body on the value of isPresented so the behaviour is now different. When the Button is pressed, instead of the sheet block being called immediately, actual body is called first, and thus a new sheet block is created and this one now uses the new value of isPresented which is true and the problem is fixed.

This workaround may be undesirable, so a way to ensure the problem doesn't happen is to use a feature of Swift called a capture list, this makes a dependency on the value of isPresented without actually having to display it, e.g.

struct SheetTestView: View {
    @State private var isPresented: Bool = false
    
    var body: some View {
        Button("Present (isPresented: \(isPresented))") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) { [isPresented] in // capture list
            Text(isPresented.description)
        }
    }
}

This trick does make body depend on isPresented in SwiftUI's eyes, so body is called when the Button action changes isPresented and then a new block is created that is passed to sheet and does have the correct value, problem solved!

malhal
  • 26,330
  • 7
  • 115
  • 133