まえがき

iOSアプリを使っている時、ふとどう実装するんだろう?と気になってしまうことありますよね?
開発者だけでしょうか。


最近だとSwiftUIの利用が増え、ているかは仕事では案件の性質によるかと思いますが…。ひとまず個人開発の範囲ではSwiftUIを積極的に利用するようになりました。簡単にUIが作れて便利です。
ただSwiftUIは、公式アプリでは利用されている実装が、意図的に隠されている場合が時々あります。なぜ公開しないかはAppleのみぞ知る、ということで我々は公開されているAPIに従う他ありません。でも実はUIKitで無理やりに呼び出す方法もあり、一部の人はそういう隠しAPIを呼んで遊んでいるというのをSNSでたまに見かけますね。

初めに戻って、iOSアプリを使っている時、ふとどう実装するんだろう(標準APIだとできないはずなのに)、と思う挙動を時々見かけますよね。UIKitならSwiftUIのできないこともできる、ということで今回はそういう実装の一つを私が理解できる範囲で作ってみました。
上の例のような隠しAPIではないのですが、SwiftUIに限界を感じた人は少なからずやったことがあるかもしれません。

ちなみに、リジェクトのリスクは0ではないと思います。あしからず。技術的興味でひとまず留めておいてください。
でも今回のような実装を使っているアプリがあなたのiPhoneにもきっとあります。

目標の実装

iPhoneで某鳥のSNSアプリを開いて、Homeタブを長押ししてみてください。

こんな感じで下からアカウントを切り替えるスイッチャーが出てきたと思います。具体的には、TabBarを長押しするとSheetを表示する挙動を作ります。

試作

TabBarとsheetを出す土台だけまずは作っておきます。

試作1号機
struct ContentView: View {
    enum Tab: Int, Hashable {
        case overview = 0
        case catalog = 1
        case stats = 2
        case settings = 3
    }
    enum ActiveSheet: String, Identifiable {
        case overviewAccount
        var id: String { rawValue }
    }
    @MainActor
    @Observable
    fileprivate final class ViewState {
        var selectedTab: Tab = .overview
        var switchCount: Int = 0
        var currentAccount: DemoAccount = .sampleA
        var activeSheet: ActiveSheet?
        func selectAccount(_ account: DemoAccount) {
            currentAccount = account
            selectedTab = .overview
            switchCount += 1
            activeSheet = nil
        }
        func openAccountSheet() {
            activeSheet = .overviewAccount
        }
    }
    @State private var state = ViewState()
    var body: some View {
        @Bindable var bindableState = state
        TabView(selection: $bindableState.selectedTab) {
            OverviewView(
                currentAccount: state.currentAccount,
                switchCount: state.switchCount
            ) {
                state.openAccountSheet()
            }
            .tabItem {
                Label("Overview", systemImage: "house.fill")
            }
            .tag(Tab.overview)
            CatalogView()
                .tabItem {
                    Label("Catalog", systemImage: "square.grid.2x2")
                }
                .tag(Tab.catalog)
            StatsView()
                .tabItem {
                    Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
                }
                .tag(Tab.stats)
            SettingsView()
                .tabItem {
                    Label("Settings", systemImage: "gearshape.fill")
                }
                .tag(Tab.settings)
        }
        .tint(.teal)
        .sheet(item: $bindableState.activeSheet) { sheet in
            switch sheet {
            case .overviewAccount:
                LongPressActionSheetView(
                    accounts: DemoAccount.samples,
                    currentAccount: state.currentAccount
                ) { account in
                    state.selectAccount(account)
                }
                .presentationDetents([
                    .height(LongPressActionSheetView.detentHeight(forAccountCount: DemoAccount.samples.count))
                ])
                .presentationDragIndicator(.visible)
            }
        }
    }
}


TabViewを置き、中に各画面を配置し、.tabItem modifierを付けてやると、よく見るレイアウトができます。またTabViewに.sheet modifierをつけ、@Stateで保持したオブジェクト内で管理しているactiveSheetにSheet種別を入れると(今は1種類ですが)シートが立ち上がる、という作りです。

作り方は色々ありますが一旦これが基本的な作り方ではないでしょうか。

さて、TabBarItemに.onLongPressGesture()をつけて実行してみるとわかるのですが、反応しません。推測ではTabBar自体がTapGestureを受け付ける作りなので先にgestureを取られている、などが考えられますが・・・
困った困った・・・そこでUIKitです。(もしSwiftUIでできるようになったら嬉しいですが)

SwiftUIへのUIKitの持ち出し

上で言ったようにUIKit、具体的にはUITabbarViewControllerをSwiftUIに持ち込みます。UIKitをSwiftUIに持ち込むには、そうUIViewControllerRepresentableですね。 

下のUIKitTabContainerをSwiftUIで持ち、SwiftUI側にTabのStateを保持している形です。

private struct UIKitTabContainer: UIViewControllerRepresentable {
    @Binding var selectedTab: ContentView.Tab
    let switchCount: Int
    let onLongPressAction: () -> Void
    func makeUIViewController(context: Context) -> MainTabBarController {
        let controller = MainTabBarController(tintColor: .systemTeal)
        controller.onSelectedTabChanged = { tab in selectedTab = tab }
        controller.onLongPressAction = onLongPressAction
        controller.apply(switchCount: switchCount)
        controller.setSelectedTab(selectedTab)
        return controller
    }
    func updateUIViewController(_ uiViewController: MainTabBarController, context: Context) {
        uiViewController.onSelectedTabChanged = { tab in selectedTab = tab }
        uiViewController.onLongPressAction = onLongPressAction
        uiViewController.apply(switchCount: switchCount)
        uiViewController.setSelectedTab(selectedTab)
    }
}

ここでのミソは、updateUIViewControllerでclosureを再適用させることで、Tabの同期を切れないようにしてやることです。

@MainActor
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate, UIGestureRecognizerDelegate {
    var onSelectedTabChanged: ((ContentView.Tab) -> Void)?
    var onLongPressAction: (() -> Void)?
    private var overviewHost: UIHostingController<AnyView>?
    private var catalogHost: UIHostingController<AnyView>?
    private var statsHost: UIHostingController<AnyView>?
    private var settingsHost: UIHostingController<AnyView>?
    private var lastSwitchCount = -1
    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self
        tabBar.tintColor = .systemTeal
    }
    func apply(switchCount: Int) {
        guard lastSwitchCount != switchCount || viewControllers == nil else { return }
        lastSwitchCount = switchCount
        // ここで UIHostingController を更新し setViewControllers する
    }
    func setSelectedTab(_ tab: ContentView.Tab) {
        selectedIndex = tab.rawValue
    }
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        guard let tab = ContentView.Tab(rawValue: tabBarController.selectedIndex) else { return }
        onSelectedTabChanged?(tab)
    }
}

MainTabBarController の責務は、以下のとおりです。

  1. タブ画面(UIHostingController)の生成と保持
  2. SwiftUI 状態を受けて再構成(apply(...)
  3. タブ選択イベントを SwiftUI 側へ返却(onSelectedTabChanged
  4. 長押しバインドと Sheet 表示(これはこの後)

ここまでの実装だけでTabのUIKit差し替え版は完了です。ここまでは標準APIで可能です。

LongPressGestureを当てる


UILongPressGestureRecognizerを付与するaddGestureRecognizerUIViewに対する関数なので、結局のところ
UITabBarの中のUITabBarItemの中のUIViewを引く必要があります。

  • UITabBar から UITabBarItem -> 取れる
  • UITabBarItem から UIView: <- これは?

そこで以下の小細工により無理やり取得します。この部分が非推奨な実装でストアの審査を通せるかわからない実装ですね。

// UITabBarからUITabBarItem
private func firstTabItemView() -> UIView? {
        guard let item = tabBar.items?.first else { return nil }
        return tabBarItemView(item)
}
// UITabBarItemからUIView
private func tabBarItemView(_ item: UITabBarItem) -> UIView? {
        performSelector("view", on: item)
}
private func performSelector(_ name: String, on object: NSObject) -> UIView? {
        let selector = NSSelectorFromString(name)
        guard object.responds(to: selector) else { return nil }
        return object.perform(selector)?.takeUnretainedValue() as? UIView
}

取得できたらジェスチャーを当ててしまえば良いのですがTabViewとの重複があるのでもう一工夫入れます。

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                       shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    true
}

これで複数ジェスチャーを同時に認識することができます。

結果

できましたね。

余談ですがiOS 26ではSheetの左右にpaddingがつくようになったようです。知らずにこれを消す方法を調べ続けていました。

某鳥アプリのSheetは左右にpaddingがないので・・・Xcode 26未満でビルドしているんでしょうね。

まとめ

SNSアプリによくあるロングタップでのアカウントスイッチャーを実装してみました。

今回のようなケースでUIViewを取る選択肢として、TabButtonのUIViewを公開した独自のCustomTabViewを実装することが思いつくかもしれませんが、アクセシビリティ的観点や細々した挙動の反映、OSのバージョンアップに追従のコストを考えるとあまり現実的ではなさそうだなという判断も事前にありました。

今回の実装は直接UIViewを拾う関係で、想定構造ではなくなった場合容易に壊れます。仮にアプリに組み込むとしても壊れて動作しない場合の代替手段は絶対に用意しましょう。(某鳥アプリは左側のメニュー上部からもアカウント切り替えができる)

SwiftUIはまだ完全ではないので、やりたいことができないことが多々あります。そういう時にUIKitを持ち出せることは覚えておいて良いと思いました。

参考



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる