Skip to content

Instantly share code, notes, and snippets.

@Amzd
Last active September 8, 2023 12:14
Show Gist options
  • Save Amzd/01e1f69ecbc4c82c8586dcd292b1d30d to your computer and use it in GitHub Desktop.
Save Amzd/01e1f69ecbc4c82c8586dcd292b1d30d to your computer and use it in GitHub Desktop.
PreferenceUIHostingController. Adds hiding home indicator and deferring system edge gestures to SwiftUI. (Don't work at the same time but I think that's normal?)
extension View {
/// Controls the application's preferred home indicator auto-hiding when this view is shown.
func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View {
preference(key: PreferenceUIHostingController.PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value)
}
/// Controls the application's preferred screen edges deferring system gestures when this view is shown. Default is UIRectEdgeNone.
func edgesDeferringSystemGestures(_ edge: UIRectEdge) -> some View {
preference(key: PreferenceUIHostingController.PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self, value: edge)
}
}
class PreferenceUIHostingController: UIHostingController<AnyView> {
init<V: View>(wrappedView: V) {
let box = Box()
super.init(rootView: AnyView(wrappedView
.onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) {
box.value?._prefersHomeIndicatorAutoHidden = $0
}
.onPreferenceChange(PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self) {
box.value?._preferredScreenEdgesDeferringSystemGestures = $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
fileprivate struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey {
typealias Value = Bool
static var defaultValue: Value = false
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue() || value
}
}
private var _prefersHomeIndicatorAutoHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersHomeIndicatorAutoHidden: Bool {
_prefersHomeIndicatorAutoHidden
}
// MARK: Preferred Screen Edges Deferring SystemGestures
fileprivate struct PreferredScreenEdgesDeferringSystemGesturesPreferenceKey: PreferenceKey {
typealias Value = UIRectEdge
static var defaultValue: Value = []
static func reduce(value: inout Value, nextValue: () -> Value) {
value.formUnion(nextValue())
}
}
private var _preferredScreenEdgesDeferringSystemGestures: UIRectEdge = [] {
didSet { setNeedsUpdateOfScreenEdgesDeferringSystemGestures() }
}
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
_preferredScreenEdgesDeferringSystemGestures
}
}
/// If you are unable to access window.rootViewController this is a method using swizzling
struct PreferenceUIHostingControllerView<Wrapped: View>: UIViewControllerRepresentable {
init(@ViewBuilder wrappedView: @escaping () -> Wrapped) {
_ = UIViewController.preferenceSwizzling
self.wrappedView = wrappedView
}
var wrappedView: () -> Wrapped
func makeUIViewController(context: Context) -> PreferenceUIHostingController {
PreferenceUIHostingController(wrappedView: wrappedView())
}
func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {}
}
import SwizzleSwift // I have a fork of this for SPM (Amzd/SwizzleSwift)
extension UIViewController {
static var preferenceSwizzling: Void = {
Swizzle(UIViewController.self) {
#selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(childForScreenEdgesDeferringSystemGestures_Amzd)
#selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(childForHomeIndicatorAutoHidden_Amzd)
}
}()
}
extension UIViewController {
@objc func childForScreenEdgesDeferringSystemGestures_Amzd() -> UIViewController? {
if self is PreferenceUIHostingController {
// dont continue searching
return nil
} else {
return search()
}
}
@objc func childForHomeIndicatorAutoHidden_Amzd() -> UIViewController? {
if self is PreferenceUIHostingController {
// dont continue searching
return nil
} else {
return search()
}
}
private func search() -> PreferenceUIHostingController? {
if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first {
return result
}
for child in children {
if let result = child.search() {
return result
}
}
return nil
}
}
@Amzd
Copy link
Author

Amzd commented May 11, 2021

Ah okay so I missed this detail https://developer.apple.com/documentation/uikit/uiviewcontroller/2887511-childforscreenedgesdeferringsyst

Apparently the system only asks the first UIViewController and if that vc doesn't return a child that the system should ask too then that's it.

You could introspect a UIView > get its window > check if the rootViewController is a PreferenceUIHostingController > if not wrap the root in a PreferenceUIHostingController.

This assumes SwiftUI doesn't change the rootViewController after the initial change and is not very safe but I can't think of a better way.

@Amzd
Copy link
Author

Amzd commented May 11, 2021

So I wrote a fix that makes this work with SwiftUI only using swizzling. It might be possible to do without swizzling but this works.

I added it as second file to this gist.

Use by wrapping your views with PreferenceUIHostingControllerView

PreferenceUIHostingControllerView {
    ExampleView()
}

@PatrikTegelberg @amrmarzouk @lezone

@smithi01
Copy link

smithi01 commented Dec 14, 2021

Hi. In the second file, I think the second 'swizzle' should be:
#selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(childForHomeIndicatorAutoHidden_Amzd)

I have tested it with this change using the ios14 SwiftUI life cycle and it seems to work well.

@Amzd
Copy link
Author

Amzd commented Dec 14, 2021

You are correct @smithi01

I updated the gist

@smithi01
Copy link

I am most grateful for this code - thank you. I dim a view in my app if it is idle for a while and use my own .dimIfIdle() modifier along with your .prefersHomeIndicatorAutoHidden(timer.idle). 'timer' is my StateObject. I simply code the view that all this happens in with "PreferenceUIHostingControllerView { PanelView() }". This is wonderful because it let me move away from AppDelegate and SceneDelegate.

@LePips
Copy link

LePips commented Nov 28, 2022

Has anybody had any luck adapting this to the iOS 16 life cycle? This no longer works for iOS 16 and while .persistentSystemOverlays(.hidden) is available, this is more preferential.

@Amzd
Copy link
Author

Amzd commented Dec 2, 2022

@LePips Do you have any more info on why this doesn't work on iOS 16? is there a new api for deferring system gestures in UIKit? as it should just use that right? or does the swizzle no longer work?

@LePips
Copy link

LePips commented Dec 2, 2022

I do not have an idea why they don't work, the swizzled methods just aren't called. Most specifically, I am looking at the home indicator but I strongly assume this would also apply to all other methods.

Here is a minimal example that works for iOS 15 but not iOS 16:

Example

The commented out appDelegate is explained below.

@main
struct PreferenceHostingDevApp: App {
    
//    @UIApplicationDelegateAdaptor(MyAppDelegate.self)
//    var appDelegate
    
    var body: some Scene {
        WindowGroup {
            PreferenceUIHostingControllerView {
                ContentView()
            }
        }
    }
}

struct ContentView: View {
    
    @State
    private var showSecondView: Bool = false
    
    var body: some View {
        ZStack {
            
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
                Text("Hello, world!")
                
                Button {
                    showSecondView = true
                } label: {
                    Text("Present")
                }
            }
        }
        .ignoresSafeArea()
        .fullScreenCover(isPresented: $showSecondView) {
            SecondView(showSecondView: $showSecondView)
        }
    }
}

struct SecondView: View {
    
    @Binding
    var showSecondView: Bool
    
    var body: some View {
        VStack {
            Text("Hello There")
            
            Button {
                showSecondView = false
            } label: {
                Text("Dismiss")
            }
        }
        .prefersHomeIndicatorAutoHidden(true)
    }
}

Other things attempted:

  • setting the scene window root view controller (via the appDelegate)
  • create another solution that uses a proxy ObservableObject which will manually call the update method on the view controller
  • wrapping SecondView in a PreferenceUIHostingControllerView

@Amzd
Copy link
Author

Amzd commented Dec 3, 2022

@LePips Hmm, is it possible that the preference key doesn’t forward from a presented view? Have you tried without the presented view?

@LePips
Copy link

LePips commented Dec 4, 2022

If you mean trying to hide the home indicator on ContentView, that does work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment