qnote Advent Calendar 2022 の3日目です。
iOS 16が登場しそろそろSwiftUIでの開発が行われる現場も増えてきたのではないでしょうか?
弊社ではFlutterでの開発案件があったのでFlutterの今後の動向も気になっています。
昨今のアプリでは大体下部にタブがあるものが多いと思います。今回プッシュ通知やスキーム、音楽系のプレイヤーの常駐しているView等の通常の遷移以外の画面や動作から選択中のタブでアプリのコンテンツ詳細画面を開く機能を実装するにあたり、Swiftに比べるとSwiftUIでの実装情報はまだまだ多くないのでどう実装するのが良いか迷うかと思います。
少し考えて出てきた案としては
- 画面毎にNotificationCenterを登録して画面が表示中で通知を受け取った場合にNavigationLinkを有効にする
- UINavigationControllerをどうにかして取得してそこにプッシュする
1の案は画面毎に通知と表示中かどうかの処理を実装する必要があり面倒で実装漏れが発生したりする可能性もあるため、2の案で実装をしていこうと思います。
(コードだけみたい方は一番最後だけ確認してください。)
環境
Xcode 14.1
iOS 13以降
アプリの構造
アプリの構造としては以下のようになっています。
ルートのViewにTabViewが配置されていて各タブにNavigationViewがある。
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let tabBarView = TabBarView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: tabBarView)
self.window = window
window.makeKeyAndVisible()
}
}
}
※iOS13でも動作できるようにSceneDelegateを使っていますが、iOS14以上の場合はデフォルトのWindowGroup
の方で問題ないです。
struct TabBarView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
VStack {
Image(systemName: "house")
Text("ホーム")
}
}
.tag(1)
SearchView()
.tabItem {
VStack {
Image(systemName: "magnifyingglass")
Text("探す")
}
}
.tag(2)
SettingView()
.tabItem {
VStack {
Image(systemName: "gearshape")
Text("設定")
}
}
.tag(3)
}
}
}
struct HomeView: View {
var body: some View {
NavigationView {
Text("ホーム")
}
}
}
他のタブも同じようなものなので省略します。
UINavigationControllerを探す
基本的にはUIKitの時と同じようにwindowからUITabBarControllerやUINavigationControllerを取得します。
まずはwindowの取得
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
let window = windowScene?.windows.first
これでUIWindowの配列が取得できます。
マルチウィンドウ対応や複数UIWindowをセットしている場合は別途処理が必要になります。
次にUITabBarControllerを探します。
let tabBarController = window?.rootViewController?.children.first as? UITabBarController
windowのrootViewControllerはUIHostingController(TabBarViewを表示しているViewController)になっています。
UIHostingControllerの子の最初にUITabBarControllerがいます。
UITabBarControllerが取得できたので選択中のタブのViewControllerを取得する処理は以下のようになります。
(ついでにここまでの処理をまとめます)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let tabBarController = windowScene.windows.first?.rootViewController?.children.first as? UITabBarController else {
return nil
}
let tabIndex = tabBarController.selectedIndex
let selectedTabRootViewController = tabBarController.viewControllers![tabIndex]
selectedTabRootViewController
が選択中のタブのルートのViewControllerになります。
UIKitを扱っている方ならselectedTabRootViewController
がUINavigationControllerになっているかと予想すると思いますが、SwiftUIではUINavigationControllerになっておらずその中まで確認していく必要があります。
(selectedTabRootViewController
はUIHostingControllerになっています)
UINavigationControllerを取得するためにViewControllerのchildrenを見ていきます。
private func findNavigationController(_ viewController: UIViewController) -> UINavigationController? {
if let navigationController = viewController as? UINavigationController {
return navigationController
}
for child in viewController.children {
if let navigationController = findNavigationController(child) {
return navigationController
}
}
return nil
}
UINavigationControllerが取得できるまで引数のViewControllerからchildrenを探しています。
ヒエラルキーを確認すると以下のようになっていました。
赤い部分のStyleContextSplitViewNavigationControllerがプライベートクラスですがUINavigationControllerにキャストすることができてUINavigationControllerとして扱うことができます。
UINavigationControllerが取得できたのであとはSwiftUIのViewからUIHostingControllerを生成しpushViewController()をするだけです!
最終的なコード
最終的には以下のコードになります。
extension UIApplication {
func pushView<Content: View>(_ view: Content) {
// モーダルを表示中や遷移不可の画面を表示している場合の処理
guard let navigationController = getCurrentTabNavigationController() else {
return
}
let hostingController = UIHostingController(rootView: view)
navigationController.pushViewController(hostingController, animated: true)
}
func getCurrentTabNavigationController() -> UINavigationController? {
// マルチウィンドウ対応アプリや複数UIWindowをセットしている場合は取得方法を書き換える
guard let windowScene = self.connectedScenes.first as? UIWindowScene,
let tabBarController = windowScene.windows.first?.rootViewController?.children.first as? UITabBarController else {
return nil
}
let tabIndex = tabBarController.selectedIndex
let selectedTabRootViewController = tabBarController.viewControllers![tabIndex]
return findNavigationController(selectedTabRootViewController)
}
private func findNavigationController(_ viewController: UIViewController) -> UINavigationController? {
if let navigationController = viewController as? UINavigationController {
return navigationController
}
for child in viewController.children {
if let navigationController = findNavigationController(child) {
return navigationController
}
}
return nil
}
}
使い方
UIApplication.shared.pushView(DetailView(id: 1))
※注意点
・マルチウィンドウ対応アプリや複数UIWindowをセットしている場合
・モーダルを表示中や遷移不可の画面を表示している場合
の考慮はしていないため適宜処理を追加してください。
何か間違い等がありましたらご指摘いただけると幸いです。