Skip to content
Source
Sign inSign up

T3SidebarTabBarView.swift

170 lines7.0 KB

1import ExpoModulesCore
2import UIKit
3​
4private final class WorkspaceTabBar: UITabBar {
5 var onContentLayout: ((CGRect) -> Void)?
6​
7 override func layoutSubviews() {
8 super.layoutSubviews()
9 // iOS 26.6 doesn't copy item identifiers onto its tab buttons; the bar holds no other controls.
10 let identified = tabButtons(in: self) { $0.accessibilityIdentifier?.hasPrefix("workspace-tab-") == true }
11 let candidates = identified.isEmpty ? tabButtons(in: self) { _ in true } : identified
12 // Pressing a tab adds a magnified copy of the row for the glass lens, without identifiers.
13 // Sizing compose from it leaves compose enlarged until the bar lays out again.
14 // The copy is many points larger; the tolerance only absorbs conversion rounding.
15 let buttons = candidates.filter {
16 let size = convert($0.bounds, from: $0).size
17 return abs(size.width - $0.bounds.width) < 1 && abs(size.height - $0.bounds.height) < 1
18 }
19 let row = buttons.reduce(CGRect.null) { $0.union(convert($1.bounds, from: $1)) }
20 if !row.isNull { onContentLayout?(row) }
21 }
22​
23 override func safeAreaInsetsDidChange() {
24 super.safeAreaInsetsDidChange()
25 invalidateIntrinsicContentSize()
26 }
27​
28 private func tabButtons(in view: UIView, matching match: (UIView) -> Bool) -> [UIView] {
29 view.subviews.filter { !$0.isHidden }.flatMap { subview in
30 if subview is UIControl, match(subview) { return [subview] }
31 return tabButtons(in: subview, matching: match)
32 }
33 }
35​
36final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
37 let onSelectionChange = EventDispatcher()
38 let onCompose = EventDispatcher()
39 private let tabBar = WorkspaceTabBar()
40 private let composeButton = UIButton(type: .system)
41 private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor)
42 private var itemsByTab: [String: UITabBarItem] = [:]
43 var tabs = ["threads", "pullRequests", "usage", "settings"] {
44 didSet { if tabs != oldValue { updateItems() } }
45 }
46 var selectedTab = "threads" { didSet { updateSelection() } }
47 var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } }
48 var compact = false {
49 didSet {
50 tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0
51 tabBar.setNeedsLayout()
52 }
53 }
54 var showsCompose = false {
55 didSet {
56 composeButton.isHidden = !showsCompose
57 tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0
58 setNeedsLayout()
59 }
60 }
61 var appearance = "light" {
62 didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light }
63 }
64​
65 required init(appContext: AppContext? = nil) {
66 super.init(appContext: appContext)
67 tabBar.itemPositioning = .fill
68 // iPad's regular bar uses a compressed inline row even with fill positioning.
69 // This floating sidebar control needs the same full-size items as a phone bar.
70 if #available(iOS 17.0, *) {
71 tabBar.traitOverrides.horizontalSizeClass = .compact
72 }
73 tabBar.delegate = self
74 tabBar.translatesAutoresizingMaskIntoConstraints = false
75 addSubview(tabBar)
76 NSLayoutConstraint.activate([
77 tabBar.leadingAnchor.constraint(equalTo: leadingAnchor),
78 tabBarTrailing,
79 tabBar.bottomAnchor.constraint(equalTo: bottomAnchor),
80 ])
81 // UIKit's floating row changes with window geometry. Follow its laid-out items
82 // instead of estimating their position from a second set of safe-area updates.
83 tabBar.onContentLayout = { [weak self] frame in self?.layoutCompose(alongside: frame) }
84 composeButton.accessibilityLabel = "New chat"
85 composeButton.addAction(UIAction { [weak self] _ in self?.onCompose() }, for: .touchUpInside)
86 composeButton.isHidden = true
87 addSubview(composeButton)
88 configureCompose()
89 prepareItems()
90 updateItems()
91 }
92​
93 private func configureCompose() {
94 var configuration: UIButton.Configuration
95 if #available(iOS 26.0, *) {
96 configuration = .glass()
97 } else {
98 configuration = .gray()
99 }
100 configuration.cornerStyle = .capsule
101 configuration.baseForegroundColor = .label
102 configuration.image = tabIcon(UIImage(systemName: "square.and.pencil"))
103 composeButton.configuration = configuration
104 }
105​
106 private func prepareItems() {
107 let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:))
108 let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)
109 let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"]
110 let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"]
111 for (tab, label) in labels {
112 let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle")
113 let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0)
114 item.accessibilityLabel = label
115 item.accessibilityIdentifier = "workspace-tab-\(tab)"
116 if tab == "threads" { item.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) }
117 itemsByTab[tab] = item
118 }
119 }
120​
121 private func updateItems() {
122 tabBar.items = tabs.enumerated().compactMap { index, tab in
123 guard let item = itemsByTab[tab] else { return nil }
124 item.tag = index
125 return item
126 }
127 updateSelection()
128 }
129​
130 private func layoutCompose(alongside frame: CGRect) {
131 guard showsCompose, frame.height > 0 else { return }
132 let row = convert(frame, from: tabBar)
133 let size = row.height + 8
134 composeButton.frame = CGRect(x: row.maxX + 16, y: row.midY - size / 2, width: size, height: size)
135 }
136​
137 override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
138 if showsCompose, let target = composeButton.hitTest(convert(point, to: composeButton), with: event) {
139 return target
140 }
141 return tabBar.hitTest(convert(point, to: tabBar), with: event)
142 }
143​
144 override func didMoveToWindow() {
145 super.didMoveToWindow()
146 updateSelection()
147 }
148​
149 // Give symbols and bitmap assets the same image box; UIKit otherwise scales symbols independently.
150 private func tabIcon(_ image: UIImage?) -> UIImage? {
151 guard let image else { return nil }
152 let canvas = CGSize(width: 28, height: 28)
153 let scale = min(24 / image.size.width, 24 / image.size.height)
154 let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
155 return UIGraphicsImageRenderer(size: canvas).image { _ in
156 image.draw(in: CGRect(x: (canvas.width - size.width) / 2, y: (canvas.height - size.height) / 2, width: size.width, height: size.height))
157 }.withRenderingMode(.alwaysTemplate)
158 }
159​
160 private func updateSelection() {
161 guard let item = itemsByTab[selectedTab], tabBar.items?.contains(item) == true else { return }
162 guard tabBar.selectedItem !== item else { return }
163 tabBar.selectedItem = item
164 }
165​
166 func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
167 guard tabs.indices.contains(item.tag) else { return }
168 onSelectionChange(["tab": tabs[item.tag]])
169 }