3

I am building a SwiftUI app where I have an overlay that is conditionally shown across my entire application like this:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                ContentView()
            }
            .safeAreaInset(edge: .bottom) {
                Group {
                    if myCondition {
                        EmptyView()
                    } else {
                        OverlayView()   
                    }
                }
            }
        }
    }
}

I would expect this to adjust the safe area insets of the NavigationView and propagate it to any content view, so content is not stuck under the overlay. At least that's how additionalSafeAreaInsets in UIKit would behave. Unfortunately, it seems that SwiftUI ignores any safeAreaInsets() on a NavigationView (the overlay will show up, but safe area is not adjusted).

While I can use a GeometryReader to read the overlay size and then set safeAreaInsets() on ContentView, this will only work for ContentView - as soon as I navigate to the next view the safe area is gone.

Is there any nice way to get NavigationView to accept additional safe area insets, either by using safeAreaInsets() or by some other way?

BlackWolf
  • 5,239
  • 5
  • 33
  • 60

2 Answers2

3

So it seems NavigationView does not adjust its safe area inset when using .safeAreaInset. If this is intended or a bug is not clear to me. Anyway, I solved this for now like this (I wanted to use pure SwiftUI, using UIKit's additionalSafeAreaInsets might be an option to):

Main App File:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                ContentView()
            }
            .environmentObject(SafeAreaController.shared)
            .safeAreaInset(edge: .bottom) {
                OverlayView()   
                    .frameReader(safeAreaController.updateAdditionalSafeArea)
            }
        }
    }
}

class SafeAreaController: ObservableObject {
    
    static let shared = SafeAreaController()
    
    @Published private(set) var additionalSafeArea: CGRect = .zero
    
    func updateAdditionalSafeArea(_ newValue: CGRect) {
        if newValue != additionalSafeArea {
            additionalSafeArea = newValue
        }
    }
    
}

struct FrameReader: ViewModifier {
    
    let changeChandler: ((CGRect) -> Void)
    
    init(_ changeChandler: @escaping (CGRect) -> Void) {
        self.changeChandler = changeChandler
    }
    
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { geometry -> Color in
                    DispatchQueue.main.async {
                        let newFrame = geometry.frame(in: .global)
                        changeChandler(newFrame)
                    }
                    
                    return Color.clear
                }
            )
    }
}

extension View {
    func frameReader(_ changeHandler: @escaping (CGRect) -> Void) -> some View {
        return modifier(FrameReader(changeHandler))
    }
}

EVERY Content View that is pushed on your NavigationView:

struct ContentView: View {

    @EnvironmentObject var safeAreaController: SafeAreaController

    var body: some View {
        YourContent()
            .safeAreaInset(edge: .bottom) {
                Color.clear.frame(height: safeAreaController.additionalSafeArea.height)
            }
}

Why does it work?

  1. In the main app file, a GeometryReader is used to read the size of the overlay created inside safeAreaInset(). The size is written to the shared SafeAreaController
  2. The shared SafeAreaController is handed as an EnvironmentObject to every content view of our navigation
  3. An invisible object is created as the .safeAreaInset of every content view with the height read from the SafeAreaController - this will basically create an invisible bottom safe area that is the same size as our overlay, thus making room for the overlay
BlackWolf
  • 5,239
  • 5
  • 33
  • 60
0
struct ContentView: View {
    
    var body: some View {
        
        NavigationView {
            ListView()
                .navigationBarTitle("Test")
        }
        .safeAreaInset(edge: .bottom) {
            HStack {
                Spacer()
                Text("My Overlay")
                    .padding()
                Spacer()
                
            }
            .background(.ultraThinMaterial)
        }
    }
}


struct ListView: View {
    
    var body: some View {
        
        List(0..<30) { item in
            NavigationLink {
                Text("Next view")
            } label: {
                Text("Item \(item)")
            }
        }
    }
}
ChrisR
  • 9,523
  • 1
  • 8
  • 26
  • But then it IS a problem with the `NavigationView`, isn't it? Because `.safeAreaInset()` doesn't work properly on `NavigationView` (for no apparent reason, or am I missing something?). And yes putting it on `ContentView` works, but is not the desired behaviour (since I want all pushed views to have the same additional safe area inset). – BlackWolf Feb 01 '22 at 20:10
  • I think there is a misconception: `NavigationView`itself is not really a view, it's more like a wrapper that tells the inner views how to behave. So the `NavigationView`itself can't deal with a safe area. – ChrisR Feb 01 '22 at 20:19
  • There definitely is something strange going on with `NavigationView`, that's true. I'm not so sure if this is expected behaviour, though. Do you have any source that this is not intended to work? I mean the whole thing is called `Navigation->View<-`, it derives from `View`, it already does change the safe area of its content (for the navigation bar) and if we look at it with a framework like SwiftUI-Introspect it appears to be a wrapper for UINavigationController, which does support this behaviour. To be honest, this seems more like bug than anything else to me, but of course I might be wrong. – BlackWolf Feb 01 '22 at 22:46
  • well it turns out you're not wrong – I was ... look at the code. I just moved it one step further down from the App struct. – ChrisR Feb 01 '22 at 23:14
  • ChrisR thanks for the code, but isn't it what I posted in my original post? Is this working for you (adjusting the safe area of the List)? – BlackWolf Feb 03 '22 at 09:40
  • arrrghh ... no it doesn't ... – ChrisR Feb 03 '22 at 10:58
  • so, after making a complete fool out of myself – why don't you put an identical safeAreaInset (e.g. as a ViewModifier) into every single of your views? – ChrisR Feb 03 '22 at 11:02
  • 2
    haha I appreciate any attempt to help :D The `.safeAreaInset` can only exist once since I want to create an overlay that stays onscreen when a new screen is pushed (so it should not be affected by the NavigationView push animation). But I solved this now by adding an EnvironmentObject that holds the bottomSafeAreaInset and then I add it to every content view. Kind of cumbersome, but seems to be the best solution without using UIKit. – BlackWolf Feb 04 '22 at 13:22