15

What's the UIKit equivalent of the prefersHomeIndicatorAutoHidden property in SwiftUI?

Scotow
  • 432
  • 7
  • 8
  • 1
    @Koen I did look at the doc and didn't found any information. But because the doc isn't finished yet, maybe the feature is available but not documented. – Scotow Jun 27 '19 at 17:13
  • Maybe in the next bèta. SwiftUI is far from finished. – koen Jun 27 '19 at 17:16

6 Answers6

13

Since I could't find this in the default API either, I made it myself in a subclass of UIHostingController.

What I wanted:

var body: some View {
    Text("I hide my home indicator")
        .prefersHomeIndicatorAutoHidden(true)
}

Since the prefersHomeIndicatorAutoHidden is a property on UIViewController we can override that in UIHostingController but we need to get the prefersHomeIndicatorAutoHidden setting up the view hierarchy, from our view that we set it on to the rootView in UIHostingController.

The way that we do that in SwiftUI is PreferenceKeys. There is lots of good explanation on that online.

So what we need is a PreferenceKey to send the value up to the UIHostingController:

struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey {
    typealias Value = Bool

    static var defaultValue: Value = false

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = nextValue() || value
    }
}

extension View {
    // Controls the application's preferred home indicator auto-hiding when this view is shown.
    func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View {
        preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value)
    }
}

Now if we add .prefersHomeIndicatorAutoHidden(true) on a View it sends the PrefersHomeIndicatorAutoHiddenPreferenceKey up the view hierarchy. To catch that in the hosting controller I made a subclass that wraps the rootView to listen to the preference change, then update the UIViewController.prefersHomeIndicatorAutoHidden:

// Not sure if it's bad that I cast to AnyView but I don't know how to do this with generics
class PreferenceUIHostingController: UIHostingController<AnyView> {
    init<V: View>(wrappedView: V) {
        let box = Box()
        super.init(rootView: AnyView(wrappedView
            .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) {
                box.value?._prefersHomeIndicatorAutoHidden = $0
            }
        ))
        box.value = self
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    private class Box {
        weak var value: PreferenceUIHostingController?
        init() {}
    }

    // MARK: Prefers Home Indicator Auto Hidden

    private var _prefersHomeIndicatorAutoHidden = false {
        didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
    }
    override var prefersHomeIndicatorAutoHidden: Bool {
        _prefersHomeIndicatorAutoHidden
    }
}

Full example that doesn't expose the PreferenceKey type and has preferredScreenEdgesDeferringSystemGestures too on git: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d

Casper Zandbergen
  • 3,419
  • 2
  • 25
  • 49
4

For SwiftUI with the new application life cycle

From SwiftUI 2.0 when using the new Application Life Cycle we need to create a new variable in our @main .app file with the wrapper:

@UIApplicationDelegateAdaptor(MyAppDelegate.self) var appDelegate

The main app file will look like this:

import SwiftUI

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(MyAppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Then we create our UIApplicationDelegate class in a new file:

import UIKit

class MyAppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let config = UISceneConfiguration(name: "My Scene Delegate", sessionRole: connectingSceneSession.role)
        config.delegateClass = MySceneDelegate.self
        return config
    }
}

Above we passed the name of our SceneDelegate class as "MySceneDelegate", so lets create this class in a separate file:

class MySceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let rootView = ContentView()
            let hostingController = HostingController(rootView: rootView)
            window.rootViewController = hostingController
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

The property prefersHomeIndicatorAutoHidden will have to be overridden in the HostingController class as usual as in the above solution by ShengChaLover:

class HostingController: UIHostingController<ContentView> {
    override var prefersHomeIndicatorAutoHidden: Bool {
        return true
    }
} 

Of course do not forget to replace contentView with the name of your view if different!

Kudos to Paul Hudson of Hacking with Swift and Kilo Loco for the hints!

multitudes
  • 2,898
  • 2
  • 22
  • 29
  • Be aware that this does not *replace* standard SwiftUI 2.0 scene with default WindowGroup, but (!) creates *second* window with your custom `HostingController`. Just in case. – Asperi Oct 31 '20 at 15:32
  • Thanks for the clarification. I am still quite new myself. If you see a better way let me know. Hope SwiftUI will get a better way to handle this in the future like it handles the status bar :) – multitudes Oct 31 '20 at 15:54
  • This technique works for my initial view but when present the next one the indicator reappears. – Mark Bridges Mar 29 '21 at 10:15
  • This is my preferred answer for iOS 15. Ironically I recreated an answer that is nearly identical, but had an Info.plist entry. This eliminates the Info.plist; so I deleted mine. – Warren Stringer Oct 02 '21 at 23:30
  • It works only for my rootView, possible to make it work for other views ? – Stranger B. Nov 03 '21 at 21:54
4

iOS 16

you can use the .persistentSystemOverlays and pass in .hidden to hide all non-transient system views that are automatically placed over our UI

Text("Goodbye home indicator, the multitask indicator on iPad, and more.")
    .persistentSystemOverlays(.hidden)
Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
  • Note: The system decides if the overlays are hidden or not. In my case that doesn't work and I don't know why... https://developer.apple.com/documentation/swiftui/view/persistentsystemoverlays(_:) – Nico S. Sep 20 '22 at 12:04
  • Even checking `requires fullscreen` in the project file doesn't help. Any ideas? – Nico S. Sep 29 '22 at 01:02
3

The only solution i found to work 100% of the time was swizzling the instance property 'prefersHomeIndicatorAutoHidden' in all UIViewControllers that way it always returned true.

Create a extension on NSObject for swizzling instance methods / properties

//NSObject+Swizzle.swift

extension NSObject {
    class func swizzle(origSelector: Selector, withSelector: Selector, forClass: AnyClass) {
          let originalMethod = class_getInstanceMethod(forClass, origSelector)
          let swizzledMethod = class_getInstanceMethod(forClass, withSelector)
          method_exchangeImplementations(originalMethod!, swizzledMethod!)
     }
}

Created extension on UIViewController this will swap the instance property in all view controller with one we created that always returns true

//UIViewController+HideHomeIndicator.swift

extension UIViewController {

   @objc var swizzle_prefersHomeIndicatorAutoHidden: Bool {
       return true
   }

   public class func swizzleHomeIndicatorProperty() {
       self.swizzle(origSelector:#selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
                    withSelector:#selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
                    forClass:UIViewController.self)
   }
}

Then call swizzleHomeIndicatorProperty() function in your App Delegate

// AppDelegate.swift

class AppDelegate: UIResponder, UIApplicationDelegate {

 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    //Override 'prefersHomeIndicatorAutoHidden' in all UIViewControllers
    UIViewController.swizzleHomeIndicatorProperty()

    return true
  }

}

if using SwiftUI register your AppDelegate using UIApplicationDelegateAdaptor

//Application.swift

@main
struct Application: App {

   @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

   var body: some Scene {
     WindowGroup {
       ContentView()
      }
   }
}
Denis Murphy
  • 1,137
  • 1
  • 11
  • 21
2

I have managed to hide the Home Indicator in my single view app using a technique that's simpler than what Casper Zandbergen proposes. It's way less 'generic' and I am not sure the preference will propagate down the view hierarchy, but in my case that's just enough.

In your SceneDelegate subclass the UIHostingController with your root view type as the generic parameter and override prefersHomeIndicatorAutoHidden property.

class HostingController: UIHostingController<YourRootView> {
    override var prefersHomeIndicatorAutoHidden: Bool {
        return true
    }
}

In the scene method's routine create an instance of you custom HostingController passing the root view as usual and assign that instance to window's rootViewController:

if let windowScene = scene as? UIWindowScene {
    let window = UIWindow(windowScene: windowScene)
    let rootView = YourRootView()
    let hostingController = HostingController(rootView: rootView)
    window.rootViewController = hostingController
    self.window = window
    window.makeKeyAndVisible()
}

Update: this will not work if you need to inject an EnvironmentObject into a root view.

Shengchalover
  • 614
  • 5
  • 10
  • This turns off the home indicator everywhere in your app, I wouldn't recommend doing that. – Casper Zandbergen Dec 11 '19 at 08:24
  • @CasperZandbergen considering the indicator is not customizable, I cannot agree that it is bad idea to hide it everywhere. The glaring white indicator looks absolutely terrible on black backgrounds. Also, this solution does not fully remove it as one may think but rather dims after a period of inactivity. – Shengchalover Dec 12 '19 at 18:32
  • Hiding it disables the close app swipe and will annoy users. You can actually change the color of it by placing a view behind it. – Casper Zandbergen Dec 14 '19 at 18:22
  • @CasperZandbergen hiding it *does not* disable swipe, the indicator reappears as soon as you begin the swipe gesture and everything works smoothly. At least in the above implementation. What annoys users is highly subjective. Personally, I am annoyed with that indicator in dark mode and I am glad I can hide it. As for the trick regarding color, will check it out. Thanks for the tip. – Shengchalover Dec 15 '19 at 21:03
2

My solution is made for one screen only (UIHostingController). It means you do not need to replace UIHostingController in the whole app and deal with AppDelegate. Thus it will not affect injection of your EnvironmentObjects into ContentView. If you want to have just one presented screen with hideable home indicator, you need to wrap your view around custom UIHostingController and present it.

This can be done so (or you can also use PreferenceUIHostingController like in previous answers if you want to change the property in runtime. But I guess it will require some more workarounds):

final class HomeIndicatorHideableHostingController: UIHostingController<AnyView> {
    init<V: View>(wrappedView: V) {
        super.init(rootView: AnyView(wrappedView))
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override var prefersHomeIndicatorAutoHidden: Bool {
        return true
    }
}

Then you have to present your HomeIndicatorHideableHostingController in UIKit style (tested on iOS 14). The solution is based on this: https://gist.github.com/fullc0de/3d68b6b871f20630b981c7b4d51c8373. If you want to adapt it to iOS 13 look through the link (topMost property is also found there).

You create view modifier for it just like fullScreenCover:

public extension View {
    /// This is used for presenting any SwiftUI view in UIKit way.
    ///
    /// As it uses some tricky way to make the objective,
    /// could possibly happen some issues at every upgrade of iOS version.
    /// This way of presentation allows to present view in a custom `UIHostingController`
    func uiKitFullPresent<V: View>(isPresented: Binding<Bool>,
                               animated: Bool = true,
                               transitionStyle: UIModalTransitionStyle = .coverVertical,
                               presentStyle: UIModalPresentationStyle = .fullScreen,
                               content: @escaping (_ dismissHandler:
                                   @escaping (_ completion:
                                       @escaping () -> Void) -> Void) -> V) -> some View {
        modifier(FullScreenPresent(isPresented: isPresented,
                               animated: animated,
                               transitionStyle: transitionStyle,
                               presentStyle: presentStyle,
                               contentView: content))
    }
}

Modifer itself:

public struct FullScreenPresent<V: View>: ViewModifier {
    typealias ContentViewBlock = (_ dismissHandler: @escaping (_ completion: @escaping () -> Void) -> Void) -> V

    @Binding var isPresented: Bool

    let animated: Bool
    var transitionStyle: UIModalTransitionStyle = .coverVertical
    var presentStyle: UIModalPresentationStyle = .fullScreen
    let contentView: ContentViewBlock

    private weak var transitioningDelegate: UIViewControllerTransitioningDelegate?

    init(isPresented: Binding<Bool>,
         animated: Bool,
         transitionStyle: UIModalTransitionStyle,
         presentStyle: UIModalPresentationStyle,
         contentView: @escaping ContentViewBlock) {
        _isPresented = isPresented
        self.animated = animated
        self.transitionStyle = transitionStyle
        self.presentStyle = presentStyle
        self.contentView = contentView
    }

    @ViewBuilder
    public func body(content: Content) -> some View {
        content
            .onChange(of: isPresented) { _ in
                if isPresented {
                    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
                        let topMost = UIViewController.topMost
                        let rootView = contentView { [weak topMost] completion in
                            topMost?.dismiss(animated: animated) {
                                completion()
                                isPresented = false
                            }
                        }
                        let hostingVC = HomeIndicatorHideableHostingController(wrappedView: rootView)

                        if let customTransitioning = transitioningDelegate {
                            hostingVC.modalPresentationStyle = .custom
                            hostingVC.transitioningDelegate = customTransitioning
                        } else {
                            hostingVC.modalPresentationStyle = presentStyle
                            if presentStyle == .overFullScreen {
                                hostingVC.view.backgroundColor = .clear
                            }
                            hostingVC.modalTransitionStyle = transitionStyle
                        }

                        topMost?.present(hostingVC, animated: animated, completion: nil)
                    }
                }
            }
    }
}

And then you use it like this:

struct ContentView: View {
    @State var modalPresented: Bool = false

    var body: some View {
        Button(action: {
            modalPresented = true
        }) {
            Text("First view")
        }
        .uiKitFullPresent(isPresented: $modalPresented) { closeHandler in
            SomeModalView(close: closeHandler)
        }
    }
}

struct SomeModalView: View {
    var close: (@escaping () -> Void) -> Void

    var body: some View {
        Button(action: {
            close({
                // Do something when dismiss animation finished
            })
        }) {
            Text("Tap to go back")
        }
    }
}
wifizone
  • 41
  • 4