| 1 | import SwiftUI |
| 2 | import UIKit |
| 3 | |
| 4 | @MainActor |
| 5 | final class KleponTabBarController: UITabBarController { |
| 6 | |
| 7 | private let appState: AppState |
| 8 | private var shouldGiveFocusToSelectedViewController = false |
| 9 | |
| 10 | init(appState: AppState) { |
| 11 | self.appState = appState |
| 12 | super.init(nibName: nil, bundle: nil) |
| 13 | } |
| 14 | |
| 15 | @available(*, unavailable) |
| 16 | required init?(coder: NSCoder) { |
| 17 | fatalError("init(coder:) has not been implemented") |
| 18 | } |
| 19 | |
| 20 | override func viewDidLoad() { |
| 21 | super.viewDidLoad() |
| 22 | viewControllers = [ |
| 23 | makeTab( |
| 24 | title: "Discover", |
| 25 | imageName: "sparkles", |
| 26 | viewController: makeDiscoverViewController() |
| 27 | ), |
| 28 | makeTab( |
| 29 | title: "Search", |
| 30 | imageName: "magnifyingglass", |
| 31 | viewController: makeSearchViewController() |
| 32 | ), |
| 33 | makeTab( |
| 34 | title: "Saved", |
| 35 | imageName: "heart", |
| 36 | viewController: makeSavedViewController() |
| 37 | ), |
| 38 | makeTab( |
| 39 | title: "Settings", |
| 40 | imageName: "gearshape.2", |
| 41 | viewController: makeSettingsViewController() |
| 42 | ), |
| 43 | ] |
| 44 | } |
| 45 | |
| 46 | // MARK: - Focus engine |
| 47 | |
| 48 | override var preferredFocusEnvironments: [UIFocusEnvironment] { |
| 49 | guard let selectedViewController, shouldGiveFocusToSelectedViewController else { |
| 50 | return super.preferredFocusEnvironments |
| 51 | } |
| 52 | shouldGiveFocusToSelectedViewController = false |
| 53 | return [selectedViewController] |
| 54 | } |
| 55 | |
| 56 | override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { |
| 57 | if let nextFocusedView = context.nextFocusedView, |
| 58 | !nextFocusedView.isDescendant(of: tabBar), |
| 59 | let previouslyFocusedView = context.previouslyFocusedView, |
| 60 | previouslyFocusedView.isDescendant(of: tabBar) |
| 61 | { |
| 62 | shouldGiveFocusToSelectedViewController = true |
| 63 | setNeedsFocusUpdate() |
| 64 | updateFocusIfNeeded() |
| 65 | return false |
| 66 | } |
| 67 | shouldGiveFocusToSelectedViewController = false |
| 68 | return true |
| 69 | } |
| 70 | |
| 71 | // MARK: - Top Shelf routing |
| 72 | |
| 73 | func handle(url: URL) { |
| 74 | guard let route = TopShelfRoute(url: url, repository: appState.contentRepository) else { |
| 75 | return |
| 76 | } |
| 77 | |
| 78 | selectedIndex = 0 |
| 79 | dismissPresentedContentIfNeeded { [weak self] in |
| 80 | self?.present(route: route) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | private func dismissPresentedContentIfNeeded(completion: @escaping () -> Void) { |
| 85 | if let activePresentedViewController = selectedViewController?.presentedViewController |
| 86 | ?? presentedViewController |
| 87 | { |
| 88 | activePresentedViewController.dismiss(animated: false, completion: completion) |
| 89 | } else { |
| 90 | completion() |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | private func present(route: TopShelfRoute) { |
| 95 | switch route { |
| 96 | case .entry(let entry): |
| 97 | let detailView = NavigationStack { |
| 98 | GuideDetailView(entry: entry) |
| 99 | } |
| 100 | .withKleponEnvironment(appState: appState) |
| 101 | let hostingController = UIHostingController(rootView: detailView) |
| 102 | hostingController.modalPresentationStyle = .fullScreen |
| 103 | presentationSourceViewController.present(hostingController, animated: true) |
| 104 | case .ask(let entry): |
| 105 | let askView = NavigationStack { |
| 106 | AskSheetView(entry: entry, initialQuestion: nil) |
| 107 | } |
| 108 | .withKleponEnvironment(appState: appState) |
| 109 | let hostingController = UIHostingController(rootView: askView) |
| 110 | hostingController.modalPresentationStyle = .fullScreen |
| 111 | presentationSourceViewController.present(hostingController, animated: true) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | private var presentationSourceViewController: UIViewController { |
| 116 | selectedViewController ?? self |
| 117 | } |
| 118 | |
| 119 | // MARK: - Tab factories |
| 120 | |
| 121 | private func makeTab( |
| 122 | title: String, |
| 123 | imageName: String, |
| 124 | viewController: UIViewController |
| 125 | ) -> UIViewController { |
| 126 | viewController.tabBarItem = UITabBarItem( |
| 127 | title: title, |
| 128 | image: UIImage(systemName: imageName), |
| 129 | selectedImage: UIImage(systemName: "\(imageName).fill") |
| 130 | ) |
| 131 | viewController.tabBarItem.accessibilityIdentifier = title.lowercased() |
| 132 | return viewController |
| 133 | } |
| 134 | |
| 135 | private func makeDiscoverViewController() -> UIViewController { |
| 136 | // DiscoverView on tvOS uses a constant binding for showingSettings (settings is a tab) |
| 137 | let view = DiscoverView(showingSettings: .constant(false)) |
| 138 | .withKleponEnvironment(appState: appState) |
| 139 | return UIHostingController(rootView: view) |
| 140 | } |
| 141 | |
| 142 | private func makeSearchViewController() -> UIViewController { |
| 143 | let view = SearchView() |
| 144 | .withKleponEnvironment(appState: appState) |
| 145 | return UIHostingController(rootView: view) |
| 146 | } |
| 147 | |
| 148 | private func makeSavedViewController() -> UIViewController { |
| 149 | let view = SavedView() |
| 150 | .withKleponEnvironment(appState: appState) |
| 151 | return UIHostingController(rootView: view) |
| 152 | } |
| 153 | |
| 154 | private func makeSettingsViewController() -> UIViewController { |
| 155 | let view = SettingsView() |
| 156 | .withKleponEnvironment(appState: appState) |
| 157 | return UIHostingController(rootView: view) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | private enum TopShelfRoute { |
| 162 | case entry(GuideEntry) |
| 163 | case ask(GuideEntry) |
| 164 | |
| 165 | init?(url: URL, repository: ContentRepository) { |
| 166 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| 167 | components.scheme?.lowercased() == "klepon", |
| 168 | let host = components.host?.lowercased(), |
| 169 | let entryID = components.queryItems?.first(where: { $0.name == "id" })?.value, |
| 170 | let entry = repository.entry(id: entryID) |
| 171 | else { |
| 172 | return nil |
| 173 | } |
| 174 | |
| 175 | switch host { |
| 176 | case "entry": |
| 177 | self = .entry(entry) |
| 178 | case "ask": |
| 179 | self = .ask(entry) |
| 180 | default: |
| 181 | return nil |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // MARK: - Environment injection helper |
| 187 | |
| 188 | extension View { |
| 189 | fileprivate func withKleponEnvironment(appState: AppState) -> some View { |
| 190 | self |
| 191 | .environmentObject(appState) |
| 192 | .environmentObject(appState.favoritesStore) |
| 193 | .environmentObject(appState.recentSearchStore) |
| 194 | .environmentObject(appState.recentlyViewedStore) |
| 195 | .environmentObject(appState.guideEngine) |
| 196 | .environmentObject(appState.accountStore) |
| 197 | } |
| 198 | } |