1

I have an app with many nested views, some which show a sheet based on a user action.

But I also have a sheet that I'd like to present on the main view based on a timer (ie, not a user action). But you can't have 2 sheets at the same time, so I'd like to check "something" to see if a sheet is already up, and not present the one from the timer.

I'd like to do this in a general way, and not check every place in the code where a sheet might be presented.

Any suggestions?

Jack
  • 2,206
  • 3
  • 18
  • 25
  • 1
    SwiftUI is MVVM oriented so it is your responsibility to hold somewhere in model/view-model that sheet is shown, so have possibility to check this. – Asperi May 26 '22 at 18:38
  • 1
    @asperi That's clumsy. It has nothing to do with the model, nor even the VM. It's strictly a view issue. It's a violation of encapsulation to have all subviews everywhere notify "somebody". It's nice to have subviews be self-contained. – Jack May 26 '22 at 19:17
  • You can have a `struct` of type `Identifiable`, to present any sheet. In your view model, use an optional `@Published var` of that type; when you change that variable, by the user or programmatically, one sheet will be presented, otherwise the value will be nil. In the views, don't use `.sheet(isPresented:)`, instead use `.sheet(item:)`, which works with `Identifiable` objects. – HunterLion May 26 '22 at 21:09
  • you can check the presentation controller in the root view controller right now. But that is likely a temporary solution. There are many issues with using the root view controller as a solution with anything for SwiftUI, I expect as SwiftUI evolves the root view controller will become less relevant. – lorem ipsum May 26 '22 at 22:14
  • "It's a violation of encapsulation to have all subviews everywhere notify "somebody"." It's exactly equivalent to the encapsulation issues of one view caring what another view is doing. To the extent that they do care (i.e. the fact that only one is allowed to be shown), that is exactly the extent to which it should be in the model as @Asperi notes. If "is a sheet being shown" is a global question, then there should be a global model that tracks it. SwiftUI Views are not objects; they do not actually display anything, they only describe. The place to store externally visible state is in models – Rob Napier May 27 '22 at 17:59
  • "you can't have 2 sheets at the same time" wrong, see: https://nilcoalescing.com/blog/ShowMultipleSheetsAtOnceInSwiftUI/ – malhal May 27 '22 at 18:01
  • @malhal This only works in a limited way, you have to nest the sheets in question. That's not my case. – Jack May 27 '22 at 23:01
  • @RobNapier But the point is that the views don't care about each other otherwise. The only reason for caring is because of the *arbitrary* complexity of the framework (if it just showed sheets on top of each other, nothing to do here). You want to avoid arbitrary complexity when you can. And this quibble of views as objects, or descriptions, or whatever, that's not the point. We're talking about *code*. When you write code you want: high-cohesion, low-coupling, encapsulation. – Jack May 27 '22 at 23:16

2 Answers2

2

Ideally there'd be something in the core framework that could be queried to answer the question "Is there a sheet being shown?", but as a commenter pointed out, that is fraught with peril.

So I just decided to leave it alone, that the "default" behavior is fine (ie, it'll defer presenting the sheet until any other sheet is dismissed). In my case this is preferred to any other gyrations.

EDIT:

Eek! I just found out that if the sheet from the timer is popped up while an Alert is showing...it ruins the app. Once you dismiss the alert, any attempt to bring up any sheet anywhere fails. It's as if things got out of sync somewhere. I believe this is similar to:

Lingering popover causes a problem with alerts

If you have alerts in your app, you don't really want to do this.

Jack
  • 2,206
  • 3
  • 18
  • 25
0

Here is how you can handle the sheets - the example below is fully functioning, just pass the view model to the environment before calling TabsView() in the App.

  1. Create an Identifiable object that will handle all the sheets in the program:
// This struct can manage all sheets
struct CustomSheet: Identifiable {

    let id = UUID()
    let screen: TypeOfSheet
    
    // All sheets should fit here
    @ViewBuilder
    var content: some View {
        switch screen {
        case .type1:
            SheetType1()
        case .type2(let text):
            SheetType2(text: text)
        default:
            EmptyView()
        }
    }

    // All types of sheets should fit here
    enum TypeOfSheet {
        case type1
        case type2(text: String)
        case none
    }
}
  1. Create one optional @Published var and one function in the view model; the var will tell the program what sheet is open:
// Code to be included in the view model, so it can
// handle AND track all the sheets
class MyViewModel: ObservableObject {
    
    // This is THE variable that will tell the code whether a sheet is open
    // (and also which one, if necessary)
    @Published var sheetView: CustomSheet?

    func showSheet(_ sheet: CustomSheet.TypeOfSheet) {

        // Dismiss any sheet that is already open
        sheetView = nil

        switch sheet {
        case .none:
            break
        default:
            sheetView = CustomSheet(screen: sheet)
        }
    }
}
  1. Usage:
  • open the sheets by calling the function viewModel.showSheet(...)
  • use .sheet(item:) to observe the type of sheet to open
  • use viewModel.sheet.screen to know what sheet is open
  • sheets can also be dismissed using viewModel.showSheet(.none)
// Example: how to use the view model to present and track sheets
struct TabsView: View {
    @EnvironmentObject var viewModel: MyViewModel
    var body: some View {
        TabView {
            VStack {
                Text("First tab. Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
                    .padding()
                Button("Open sheet type 1") {
                    
                    // Show a sheet of the first type
                    viewModel.showSheet(.type1)
                }
            }
            .tabItem {Label("Tab 1", systemImage: "house")}
            
            VStack {
                Text("Second tab. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
                    .padding()
                Button("Open sheet type 2") {
                    
                    // Show a sheet of the second type
                    viewModel.showSheet(.type2(text: "parameter"))
                }
            }
            .tabItem {Label("Tab 2", systemImage: "plus")}

        }
        
        // Open a sheet - the one selected in the view model
        .sheet(item: $viewModel.sheetView) { sheet in
            sheet.content
                .environmentObject(viewModel)
        }
    }
}

The following code completes the minimal reproducible example:

// Just some sample views for the sheets
struct SheetType1: View {
    @EnvironmentObject var viewModel: MyViewModel
    var body: some View {
        Text("Takes no parameters. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
    }
}
struct SheetType2: View {
    @EnvironmentObject var viewModel: MyViewModel
    let text: String
    var body: some View {
        Text("Takes a string: \(text). Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
    }
}

@main
struct MyApp: App {

    let viewModel = MyViewModel()

    var body: some Scene {
        WindowGroup {
            TabsView()
                .environmentObject(viewModel)
        }
    }
}
HunterLion
  • 3,496
  • 1
  • 6
  • 18
  • FYI in SwiftUI the View struct does the same job as a UIKit view model object. You can avoid @ViewBuilder and optimise by using sub View structs. – malhal May 27 '22 at 18:04
  • @malhal: you’re right; that could just be an identifiable view. – HunterLion May 27 '22 at 18:08