main
swift 198 lines 6.46 KB
Raw
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 }