【SwiftUI】非操作時にホームバーを非表示にする方法(HomeIndicatorAutoHidden)

SwiftUI

全画面をフルに使いたいアプリでは邪魔になりがちなホームバー(ホームインジケーター・HomeIndicator)ですが、
現在はSwiftUIの標準機能としては非表示にする方法がありません。

従ってUIViewControllerのprefersHomeIndicatorAutoHiddenを利用する事になります。
このprefersHomeIndicatorAutoHiddenが曲者なのですが、
今回簡単に操作する方法を見つけました。

iOS14でも動作するので、今後SwiftUIでHomeIndicatorAutoHiddenが実装されても、
最新iOSバージョンを要求され互換を切りたくない場合には、
この方法を継続して使う事になると思います。

コピペでも簡単に動かせますし、おそらくiPadOSのSwift Playgroundsでも大丈夫だと思います。
※Playgroundsは実行出来ましたが、ホームボタン付きのiPadしか持っていないので非表示の確認ができていません。

ソースコード

まず先に結論のソースコードです。

import SwiftUI

@main
struct HomeBarTestApp: App {
    
    init(){
        UIViewController.swizzle()
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension UIViewController{
    
    class func swizzle(){
        guard let originalMethod = class_getInstanceMethod(self, #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden)),
              let swizzledMethod = class_getInstanceMethod(self, #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden)) else {
            assertionFailure("The methods are not found!")
            return
        }
        
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
    
    @objc var swizzle_prefersHomeIndicatorAutoHidden : Bool { true }

}

これだけで出来ます。
SwiftUIのライフサイクルを崩さずに簡単に実装する事が出来ます。

簡単な解説

ホームバーを非表示にするにはprefersHomeIndicatorAutoHiddenがtrueを返す必要があります。

正攻法だと非常に面倒なのでMethod Swizzlingをします。
(SwiftUIから行なっている時点で正攻法か微妙な所ではありますが・・・)
Method Swizzlingとは簡単に言うと既存のメソッドを差し替える事です。

これを使用してtrueを返すだけメソッドを作ります。
そして差し替えをAppのinitで行いました。

これだけで常に、非操作時に自動的にホームバーが非表示なります。

詳細な解説

prefersHomeIndicatorAutoHidden

UIViewControllerの「プロパティ」です。
trueが返ると非操作時にHomeIndicatorが時間経過で非表示になります。
SwiftUIも大元にrootViewControllerが存在している為有効です。

インスタンス化した後に変更したい場合はsetNeedsUpdateOfHomeIndicatorAutoHidden()を実行する必要があります。

察しのいい人はすぐに分かったと思いますが、プロパティなのでextensionでoverrideできません。
その為、今回はMethod Swizzlingで差し替えを行いました。

なお、無難に書き換えを行う場合は継承する必要があります。
rootViewControllerに対して行わないといけない為、UIViewControllerRepresentableでは不可能でした。
その為、継承したUIViewControllerを使用するには、
SceneDelegateが必要となりSwiftUIのライフサイクルが崩れます。

Scene周りがUIKitのライフサイクルになった上で、UIKit側のものも一部動きません。
他にも影響が出る所は色々あるので、凝ったものを作ろうとすると悲惨です。(経験談)

メソッドの差し替えについて

まず初めにMethod Swizzlingは既存のメソッドの差し替えを行う事です。
obj-cのものなので色々な人が解説していると思われるのでSwizzlingの解説は省略しますが、
差し替えと聞いてわかる通り非常に重大な影響を出しやすいので、
他で安易に使わず、使った場合は分かりやすくして下さい。

extension UIViewController{
    
    class func swizzle(){
        guard let originalMethod = class_getInstanceMethod(self, #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden)),
              let swizzledMethod = class_getInstanceMethod(self, #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden)) else {
            assertionFailure("The methods are not found!")
            return
        }
        
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
    
    @objc var swizzle_prefersHomeIndicatorAutoHidden : Bool { true }

}

今回やっていることは単純でclass_getInstanceMethodでメソッドを取得してmethod_exchangeImplementationsで目的のメソッドと入れ替えます。
prefersHomeIndicatorAutoHiddenとswizzle_〜ですね。

swizzleを繰り返し呼ぶ事は可能ですが、切り替えがしたいならばtrueの代わりにstaticな変数を用意してreturnした方が無難だと思います。

SwiftUIでの実行

メソッドの差し替えを行うメソッドを作っただけなので、どこかで実行する必要があります。
無難にAppのinitで行なっています。

import SwiftUI

@main
struct HomeBarTestApp: App {
    
    init(){
        UIViewController.swizzle()
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

swizzle()メソッドの実行自体はどこでも大丈夫ですが、
UIViewControllerがインスタンス化されてからの場合は
setNeedsUpdateOfHomeIndicatorAutoHidden()の実行も必要になるのでinitで行なってしまっています。

まとめ

以上、ホームバー非表示の方法でした。

この方法ではAppDelegateやSceneDelegateに手を付けず、
SwiftUIのライフサイクルをそのままにHomeIndicatorAutoHiddenを実装しました。
外部ライブラリも不要で、Info.plistにも手をつける必要が無いので、
Playgroundsでも実装可能と思われます。

最悪コピペでも動作する程度の内容なので、
Method Swizzlingを使った事だけは注意しつつ試して見て下さい。

SwiftUIでHomeIndicatorAutoHiddenに悩んだ人は多いと思うので、
これにより、よりSwiftUIが使われる様になると幸いです。

おまけ

切り替えとSwiftUIらしい使い方

切り替えがしたい場合はあると思いますし、
SwiftUIならModifierのメソッドチェーンの様に書きたいと思います。

ざっと以下の様なものを用意してみました。

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink("Hidden View"){
                    Text("Hidden").homeIndicatorAutoHidden(true)
                }
                NavigationLink("Not Hidden View"){
                    Text("Not Hidden").homeIndicatorAutoHidden(false)
                }
            }.homeIndicatorAutoHidden(true)
        }
    }
}

struct HomeIndicatorAutoHiddenStatus {
    static var status = false {
        didSet{
            UIApplication.shared.keyWindow?.rootViewController?.setNeedsUpdateOfHomeIndicatorAutoHidden()
        }
    }
}

extension View {
    func homeIndicatorAutoHidden(_ bool:Bool) -> some View {
        self.onAppear{
            HomeIndicatorAutoHiddenStatus.status = bool
        }
    }
}

SwiftUIがViewを構築するタイミングは計りにくい場合があるのでonAppearでやっています。
表示時のみなので変更がある場合は遷移前後の両方に指定する必要があります。
戻す処理が欲しい場合は、変更前を保持してonDisappearで戻すなどして下さい。

この辺りは工夫で色々できると思うので1例として参考までに

UIViewControllerを継承する方法

Method Swizzlingをしない場合です。
恐らくこの方法を紹介している所が殆どですが、SwiftUIライフサイクルが崩れるので非推奨です。

import SwiftUI

@main
struct HomeIndicatorHiddenApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
//            ContentView()//SceneDelegateのContentViewの裏に描画される為不要
        }
    }
}

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

class SceneDelegate: 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)
            window.rootViewController = RootViewHostingController(rootView: ContentView())
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

class RootViewHostingController: UIHostingController<ContentView> {

    override var prefersHomeIndicatorAutoHidden: Bool {
        true
    }
    
}

結構長い上にWindowGroup内のContentViewが使えないのでSwiftUIライフサイクルが崩れます。
厳密に言うとrootViewController側のContentViewの裏に描画されています。

二重に描画されて無駄になる上に、表に無いのでscenePhaseはstaticなクラスで無理矢理ざっくりした処理しかできません。
SceneStorageに至っては裏と表で別Sceneな上にrootViewController側が使用できません。

内容的には見た通りで、
AppではUIApplicationDelegateAdaptorでAppDelegateを有効にします。
AppDelegateではUISceneConfigurationにSceneDelegateを指定します。

SceneDelegateはrootViewControllerを引っ張り出してUIHostingControllerのサブクラスに置き換えます。
このサブクラスでprefersHomeIndicatorAutoHiddenをoverrideします。
UIHostingControllerなのでここからContentViewを呼びます。

実はiOS13の時のSwiftUIの処理がおおよそこんな感じでした。
殆どrootViewControllerだけ差し替えた感じですね。
ベースがUIKitでiOS13時点のコードなのでiOS14からのScene系の処理が軒並みダメになります。

コメント

タイトルとURLをコピーしました