Search code examples
iosswiftswiftuiviewmodifier

Overlaying image on scenePhase when sheet is open


I've made a ViewModifier for my app so that when it goes into .background phase it shows a splash of an image. However, when a .sheet or a .fullScreenCover is presented it doesn't seem to be triggering the modifier or detecting scenePhase changes.

I've seen that you have to pass the environment from presentation to presentation which works for a 1-2 views, but if I want it to operate on the ViewModifier is it possible? Or is this a bug?

I feel like having to pass either .maskView() to every screen or passing the @Environment() or .environment() to every view too seems wrong for Swift.

Main view

struct TestOverlay: View {
    @State private var showSheet: Bool = false
    var body: some View {
        Button("Show sheet") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Button("Hide sheet") {
                showSheet = false
            }
        }
        .maskView()
    }
}

Modifier

struct MaskingViewModifier: ViewModifier {
    @Environment(\.scenePhase) private var scenePhase
    @State private var currentPhase: ScenePhase = .active
    func body(content: Content) -> some View {
        ZStack {
            content
                .blur(radius: currentPhase == .active ? 0 : 5)
            Color(.systemBackground)
                .ignoresSafeArea()
                .opacity(currentPhase == .active ? 0 : 0.4)
            Image(systemName: "xmark")
                .resizable()
                .shadow(radius: 5)
                .frame(width: 180, height: 180)
                .opacity(currentPhase == .active ? 0 : 1)
        }
        .ignoresSafeArea()
        .animation(.default, value: currentPhase)
        .onChange(of: scenePhase) { phase in
            switch phase {
                case .active:
                    currentPhase = .active
                case .inactive:
                    currentPhase = .inactive
                case .background:
                    currentPhase = .background
                @unknown default:
                    currentPhase = .active
            }
        }
    }
}

Extension

extension View {
    func maskView() -> some View {
        return modifier(MaskingViewModifier())
    }
}

Solution

  • If onChange(of: scenePhase) does not get triggered this is indeed a bug. (Edit: I think it only works on the App struct level.)

    Note that you do not have to pass environments for every child view, but only when presented as sheets.

    The masking of the view will however never hide the sheet as the sheet is presented above the view modifier. The order of applying .sheet does not matter unfortunately.

    One solution would be to fall back to UIKit, and present the blur on an overlaid UIWindow.

    You could use something like this:

    ... // some View
    
      var window: UIWindow?
    
      var body: some View {
        someView
          .onChange(of: scenePhase) { phase in
              // some logic if blur should be showed:
              UIApplication.shared.presentOnOverlayWindow(SomeBlurView())
          }
        }
    
    ...
    
    
    extension UIApplication {
      /// Returns a window to wich a strong reference needs to be kept for the duration of the presentation of the view controller.
      /// Otherwise the window is deallocated and removed from the view hierarchy.
      static func presentOnOverlayWindow<V: View>(_ view: V) -> UIWindow? {
        let scene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive }
          ?? UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundInactive }
          ?? UIApplication.shared.connectedScenes.first
    
        guard let windowScene = scene as? UIWindowScene else {
          return nil
        }
    
        let window = UIWindow(windowScene: windowScene)
        let rootViewController = UIViewController()
        window.rootViewController = rootViewController
    
        window.makeKeyAndVisible()
        rootViewController.present(UIHostingController(rootView: view), animated: true)
        return window
      }
    }
    
    

    This is only presenting the blur, but you will need to also remove it through the stored UIWindow ref.