rayx
rayx

Reputation: 1680

Sheet can't be dismissed when removing a tab

In my app, I have two tabs. The second tab is shown or hidden based on some condition. I find if there is a sheet being presented in the second tab when the tab is to be hidden, the sheet can't be dismissed.

The issue can be consistently reproduced with the code below. To reproduce it, click tab 2, then click "Present Sheet", then click "Hide Tab 2". You will see the sheet isn't removed, though the tab containing it (that is, tab 2) has been removed (you can drag the sheet down to verify it).

It seems a SwiftUI bug to me. Does anyone know how to work around it? I'm close to finish my app but hit this unexpected issue :( Any help will be much appreciated.

struct ContentView: View {
    @State var showTab2: Bool = true
    
    var body: some View {
        TabView {
            // tab 1
            NavigationView {
                Text("Tab 1")
            }
            .tabItem {
                Label("Tab 1", systemImage: "1.circle")
            }
            // tab 2
            if showTab2 {
                NavigationView {
                    Tab2(showTab2: $showTab2)
                }
                .tabItem {
                    Label("Tab 2", systemImage: "2.circle")
                }
            }
        }
    }
}

struct Tab2: View {
    @State var showSheet: Bool = false
    @Binding var showTab2: Bool

    var body: some View {
        VStack(spacing: 12) {
            Text("Tab 2")
            Button("Click to present sheet") {
                showSheet = true
            }
        }
        .sheet(isPresented: $showSheet, onDismiss: nil) {
            NavigationView {
                MySheet(showTab2: $showTab2)
            }
        }
    }
}

struct MySheet: View {
    @Environment(\.dismiss) var dismiss
    @Binding var showTab2: Bool

    var body: some View {
        Button("Click to hide tab 2") {
            // dismiss() works fine if I comment out this line.
            showTab2 = false
            dismiss()
        }
    }
}

I have submitted feedback on this to Apple, but I'm not optimistic for any reply (I have never received one).

Update:

The issue can be reproduced in many other scenarios where no sheet is involved. So, the second approach @Asperi gave is not a general solution.

Upvotes: 1

Views: 256

Answers (2)

rayx
rayx

Reputation: 1680

@Asperi gave a great answer. But it's not straightforward to apply his approaches in actual app. I'll explain why and how to do it below.

The key idea in Asperi's approaches is that, since the UI changes have race condition, they should be performed in two steps. In both approaches the sheet is dismissed first, then the tab is hidden.

In practice, however, it may not be obvious how to decouple the two steps. For example, my app works this way (I think it's typical):

  • The sheet contains a form and call data model API to mutate data model when the form is submitted by user.
  • Since the data model API may fail, the sheet doesn't dismiss itself as soon as user submits the form. Instead it does that only when the API call succeeds (the API call is synchronous).
  • When the data model is mutated, it may trigger the condition to hide the tab.

Note the item 2 and 3. It means the sheet have to call data model API first, which may hide the tab, and then dismiss itself.

It took me a while to think out the solution - introduce a dedicated state to control show/hide the tab and hence decouple the two steps. Now the issue left is how to synchronize data model change to that state. Since the purpose is to make them to appear as two separate changes to UI, we can't use Combine. It can be messy if not implemented property because data model can be mutated from everywhere (e.g. Form, ActionSheet, or just Button). Fortunately I find a very elegant approach:

.onChange(of: model.showTab2) { value in
    // In my experiments async() works fine, but just to be on the safe side...
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        // This is a state outside data model. It hides/shows tab2.
        showTab2 = value
    }   
}

This is another example that there is no problem that can't be solved by adding another layer of abstraction :)

Upvotes: 0

Asperi
Asperi

Reputation: 257729

Well, here we see conflict of actions (due to racing): async sheet closing (due to animation) and sync tab removing.

Here are possible approaches:

  1. delay tab removing after sheet closed (implicit way)
Button("Click to hide tab 2") {
    dismiss()
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {  // << here !!
        showTab2 = false
    }
}
  1. remove tab after sheet closed (explicit way)
.sheet(isPresented: $showSheet, onDismiss: { showTab2 = false }) { // << here !!
    NavigationView {
        MySheet(showTab2: $showTab2)
    }
}

Note: Actually when view knows/manages something for parent of parent is not very good design, so option 2 (maybe with some additional conditions/callbacks) are more preferable.

Upvotes: 1

Related Questions