Search code examples
iosswiftuiuikit

How to dynamically hide the status bar and the home indicator in SwiftUI?


I'm working on a fractal clock app that displays animated fractals based on clock hands in its main view. I want users of my app to be able to enter a fullscreen mode where all unnecessary UI is temporarily hidden and only the animation remains visible. The behavior I'm looking for is similar to Apple's Photos app where one can tap on the currently displayed image so that the navigation bar, the bottom bar, the status bar and the home indicator fade out until the image is tapped again.

Hiding the navigation bar and the status bar was as easy as finding the right view modifiers to pass the hiding condition to. But as far as I know it is currently not possible in SwiftUI to hide the home indicator without bringing in UIKit.

On Stack Overflow I found this solution by Casper Zandbergen for conditionally hiding the home indicator and adopted it for my project. It works but sadly in comes with an unacceptable side effect: The main view now no longer extends under the status bar and the home indicator which has two implications:

  1. When hiding the status bar with the relevant SwiftUI modifier the space for the main view grows by the height of the hidden status bar interrupting the display of the fractal animation.
  2. In place of the hidden home indicator always remains a black bottom bar preventing the fullscreen presentation of the main view.

I hope somebody with decent UIKit experience can help me with this. Please keep in mind that I'm a beginner in SwiftUI and that I have basically no prior experience with UIKit. Thanks in advance!

import SwiftUI

struct ContentView: View {
    @StateObject var settings = Settings()
    
    @State private var showSettings = false
    @State private var hideUI = false
    
    var body: some View {
        NavigationView {
            GeometryReader { proxy in
                let radius = 0.5 * min(proxy.size.width, proxy.size.height) - 20
                FractalClockView(settings: settings, clockRadius: radius)
            }
            .ignoresSafeArea(.all)
            .toolbar {
                Button(
                    action: { showSettings.toggle() },
                    label: { Label("Settings", systemImage: "slider.horizontal.3") }
                )
                    .popover(isPresented: $showSettings) { SettingsView(settings: settings) }
            }
            .navigationBarTitleDisplayMode(.inline)
            .onTapGesture {
                withAnimation { hideUI.toggle() }
            }
            .navigationBarHidden(hideUI)
            .statusBar(hidden: hideUI)
            .prefersHomeIndicatorAutoHidden(hideUI) // Code by Amzd
        }
        .navigationViewStyle(.stack)
    }
}

Solution

  • I was able to solve the problem with the SwiftUI view not extending beyond the safe area insets for the status bar and the home indicator by completely switching to a storyboard based project template and embedding my views through a custom UIHostingController as described in this solution by Casper Zandbergen. Before I was re-integrating the hosting controller into the SwiftUI view hierarchy by wrapping it with a UIViewRepresentable instance, which must have caused the complications in handling the safe area.

    By managing the whole app through the custom UIHostingController subclass it was even easier to get the hiding of the home indicator working. As much as I love SwiftUI I had to realize that, with its current limitations, UIKit was the better option here.

    Final code (optimized version of the solution linked above):

    ViewController.swift

    import SwiftUI
    import UIKit
    
    struct HideUIPreferenceKey: PreferenceKey {
        static var defaultValue: Bool = false
        
        static func reduce(value: inout Bool, nextValue: () -> Bool) {
            value = nextValue() || value
        }
    }
    
    extension View {
        func userInterfaceHidden(_ value: Bool) -> some View {
            preference(key: HideUIPreferenceKey.self, value: value)
        }
    }
    
    class ViewController: UIHostingController<AnyView> {
        init() {
            weak var vc: ViewController? = nil
            super.init(
                rootView: AnyView(
                    ContentView()
                        .onPreferenceChange(HideUIPreferenceKey.self) {
                            vc?.userInterfaceHidden = $0
                        }
                )
            )
            vc = self
        }
    
        @objc required dynamic init?(coder: NSCoder) {
            weak var vc: ViewController? = nil
            super.init(
                coder: coder,
                rootView: AnyView(
                    ContentView()
                       .onPreferenceChange(HideUIPreferenceKey.self) {
                           vc?.userInterfaceHidden = $0
                       }
               )
            )
            vc = self
        }
        
        private var userInterfaceHidden = false {
            didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
        }
        
        override var prefersStatusBarHidden: Bool {
            userInterfaceHidden
        }
        
        override var prefersHomeIndicatorAutoHidden: Bool {
            userInterfaceHidden
        }
    }