capacitor-native-tabbar 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ import Foundation
2
+ import UIKit
3
+ import Capacitor
4
+ import WebKit
5
+
6
+ @objc public class CapTabbar: NSObject {
7
+ private var tabBar: UITabBar?
8
+ private var tabItems: [TabItemData] = []
9
+ private var selectedId: String = ""
10
+ private weak var parentView: UIView?
11
+ private var onTabSelected: ((String) -> Void)?
12
+ private var heightConstraint: NSLayoutConstraint?
13
+ private var baseContentInset: UIEdgeInsets = .zero
14
+ private var baseIndicatorInset: UIEdgeInsets = .zero
15
+ private var insetsCaptured = false
16
+
17
+ struct TabItemData {
18
+ let id: String
19
+ let label: String
20
+ let base64Icon: String?
21
+ let base64ActiveIcon: String?
22
+ }
23
+
24
+ func setTabSelectedCallback(_ callback: @escaping (String) -> Void) {
25
+ onTabSelected = callback
26
+ }
27
+
28
+ func decodeBase64ToImage(_ base64: String?) -> UIImage? {
29
+ guard let base64 = base64, !base64.isEmpty else { return nil }
30
+ let cleaned = base64.trimmingCharacters(in: .whitespacesAndNewlines)
31
+ let data: Data?
32
+ if cleaned.hasPrefix("data:") {
33
+ data = URL(string: cleaned).flatMap { try? Data(contentsOf: $0) }
34
+ } else {
35
+ let base64Part = cleaned.contains(",") ? String(cleaned.split(separator: ",").last ?? "") : cleaned
36
+ data = Data(base64Encoded: base64Part, options: .ignoreUnknownCharacters)
37
+ }
38
+ guard let imageData = data, let image = UIImage(data: imageData) else { return nil }
39
+ if image.size.width < 2 || image.size.height < 2 { return nil }
40
+ return image.withRenderingMode(.alwaysOriginal)
41
+ }
42
+
43
+ private func placeholderImage() -> UIImage {
44
+ return (UIImage(systemName: "photo.badge.exclamationmark") ?? UIImage(systemName: "circle.fill") ?? UIImage())
45
+ .withRenderingMode(.alwaysTemplate)
46
+ }
47
+
48
+ private func tabBarHeight() -> CGFloat {
49
+ guard let parent = parentView else { return 49 }
50
+ return 49 + parent.safeAreaInsets.bottom
51
+ }
52
+
53
+ private func uiColorFromHex(_ hex: String?) -> UIColor? {
54
+ guard var hex = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !hex.isEmpty else { return nil }
55
+ if hex.hasPrefix("#") { hex = String(hex.dropFirst()) }
56
+ var rgb: UInt64 = 0
57
+ guard Scanner(string: hex).scanHexInt64(&rgb) else { return nil }
58
+ let r, g, b, a: CGFloat
59
+ if hex.count == 6 {
60
+ r = CGFloat((rgb >> 16) & 0xff) / 255
61
+ g = CGFloat((rgb >> 8) & 0xff) / 255
62
+ b = CGFloat(rgb & 0xff) / 255
63
+ a = 1
64
+ } else if hex.count == 8 {
65
+ r = CGFloat((rgb >> 24) & 0xff) / 255
66
+ g = CGFloat((rgb >> 16) & 0xff) / 255
67
+ b = CGFloat((rgb >> 8) & 0xff) / 255
68
+ a = CGFloat(rgb & 0xff) / 255
69
+ } else { return nil }
70
+ return UIColor(red: r, green: g, blue: b, alpha: a)
71
+ }
72
+
73
+ func show(parent: UIView, tabs: [TabItemData], selectedId: String, labelColor: String? = nil, labelColorActive: String? = nil) {
74
+ self.parentView = parent
75
+ self.tabItems = tabs
76
+ self.selectedId = selectedId
77
+
78
+ removeTabBar()
79
+
80
+ let tabBar = UITabBar()
81
+ tabBar.delegate = self
82
+ tabBar.translatesAutoresizingMaskIntoConstraints = false
83
+
84
+ let appearance = UITabBarAppearance()
85
+ appearance.configureWithDefaultBackground()
86
+ let normalColor = uiColorFromHex(labelColor) ?? UIColor.label
87
+ let selectedColor = uiColorFromHex(labelColorActive) ?? UIColor.systemBlue
88
+ appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: normalColor]
89
+ appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: selectedColor]
90
+ tabBar.standardAppearance = appearance
91
+ if #available(iOS 15.0, *) {
92
+ tabBar.scrollEdgeAppearance = appearance
93
+ }
94
+ self.tabBar = tabBar
95
+
96
+ var items: [UITabBarItem] = []
97
+ for (index, tab) in tabs.enumerated() {
98
+ let (image, selectedImage) = imageForTab(tab, isSelected: tab.id == selectedId)
99
+ let item = UITabBarItem(title: tab.label, image: image, selectedImage: selectedImage)
100
+ item.tag = index
101
+ items.append(item)
102
+ }
103
+ tabBar.setItems(items, animated: false)
104
+
105
+ let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId }) ?? 0
106
+ tabBar.selectedItem = items[selectedIndex]
107
+
108
+ parent.addSubview(tabBar)
109
+ let height = tabBarHeight()
110
+ heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: height)
111
+ NSLayoutConstraint.activate([
112
+ tabBar.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
113
+ tabBar.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
114
+ tabBar.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
115
+ heightConstraint!
116
+ ])
117
+
118
+ captureBaseInsetsIfNeeded(from: parent)
119
+ applyWebViewInsets(to: parent)
120
+ }
121
+
122
+ func hide() {
123
+ guard let parent = parentView else { return removeTabBar() }
124
+ removeWebViewInsets(from: parent)
125
+ removeTabBar()
126
+ }
127
+
128
+ func setSelectedTab(tabId: String) {
129
+ guard let index = tabItems.firstIndex(where: { $0.id == tabId }) else { return }
130
+ guard selectedId != tabId else { return }
131
+ selectedId = tabId
132
+ tabBar?.selectedItem = tabBar?.items?[index]
133
+ updateItemImages()
134
+ }
135
+
136
+ func getState() -> (visible: Bool, activeTabId: String) {
137
+ let visible = tabBar != nil
138
+ return (visible, visible ? selectedId : "")
139
+ }
140
+
141
+ private func imageFromBundle(_ name: String) -> UIImage? {
142
+ UIImage(named: name)
143
+ }
144
+
145
+ private func imageForTab(_ tab: TabItemData, isSelected: Bool) -> (UIImage, UIImage) {
146
+ let inactiveB64 = tab.base64Icon
147
+ let activeB64 = tab.base64ActiveIcon ?? tab.base64Icon
148
+ if let iconStr = inactiveB64, let img = decodeBase64ToImage(iconStr) {
149
+ let activeImg = (activeB64 != nil ? decodeBase64ToImage(activeB64 ?? "") : nil) ?? img
150
+ return (resizeForTabBar(img) ?? placeholderImage(), resizeForTabBar(activeImg) ?? placeholderImage())
151
+ }
152
+ let bundleInactive = imageFromBundle(tab.id) ?? imageFromBundle(tab.id.replacingOccurrences(of: " ", with: "_").replacingOccurrences(of: "-", with: "_"))
153
+ let bundleActive = imageFromBundle("\(tab.id)_active") ?? imageFromBundle("\(tab.id)_active".replacingOccurrences(of: " ", with: "_").replacingOccurrences(of: "-", with: "_"))
154
+ return (
155
+ resizeForTabBar(bundleInactive) ?? placeholderImage(),
156
+ resizeForTabBar(bundleActive ?? bundleInactive) ?? placeholderImage()
157
+ )
158
+ }
159
+
160
+ private func resizeForTabBar(_ image: UIImage?) -> UIImage? {
161
+ guard let image = image else { return nil }
162
+ let maxSize: CGFloat = 25
163
+ let size = image.size
164
+ guard size.width > maxSize || size.height > maxSize else { return image.withRenderingMode(.alwaysOriginal) }
165
+ let scale = min(maxSize / size.width, maxSize / size.height)
166
+ let newSize = CGSize(width: size.width * scale, height: size.height * scale)
167
+ let renderer = UIGraphicsImageRenderer(size: newSize)
168
+ let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) }
169
+ return resized.withRenderingMode(.alwaysOriginal)
170
+ }
171
+
172
+ private func updateItemImages() {
173
+ guard let tabBar = tabBar, let items = tabBar.items else { return }
174
+ for (index, tab) in tabItems.enumerated() where index < items.count {
175
+ let (image, selectedImage) = imageForTab(tab, isSelected: tab.id == selectedId)
176
+ items[index].image = image
177
+ items[index].selectedImage = selectedImage
178
+ }
179
+ }
180
+
181
+ private func removeTabBar() {
182
+ tabBar?.removeFromSuperview()
183
+ tabBar = nil
184
+ }
185
+
186
+ private func webViewScrollView(from view: UIView) -> UIScrollView? {
187
+ (view as? WKWebView)?.scrollView
188
+ }
189
+
190
+ private func captureBaseInsetsIfNeeded(from parent: UIView) {
191
+ guard !insetsCaptured, let scrollView = webViewScrollView(from: parent) else { return }
192
+ baseContentInset = scrollView.contentInset
193
+ baseIndicatorInset = scrollView.verticalScrollIndicatorInsets
194
+ insetsCaptured = true
195
+ }
196
+
197
+ private func applyWebViewInsets(to parent: UIView) {
198
+ guard let scrollView = webViewScrollView(from: parent) else { return }
199
+ captureBaseInsetsIfNeeded(from: parent)
200
+ let extra = tabBarHeight()
201
+ var content = baseContentInset
202
+ content.bottom = max(content.bottom, baseContentInset.bottom + extra)
203
+ var indicators = baseIndicatorInset
204
+ indicators.bottom = max(indicators.bottom, baseIndicatorInset.bottom + extra)
205
+ scrollView.contentInset = content
206
+ scrollView.verticalScrollIndicatorInsets = indicators
207
+ }
208
+
209
+ private func removeWebViewInsets(from parent: UIView) {
210
+ guard let scrollView = webViewScrollView(from: parent), insetsCaptured else { return }
211
+ scrollView.contentInset = baseContentInset
212
+ scrollView.verticalScrollIndicatorInsets = baseIndicatorInset
213
+ }
214
+
215
+ static func parseTabs(from array: [JSValue]) -> [TabItemData] {
216
+ return array.compactMap { value -> TabItemData? in
217
+ guard let dict = value as? JSObject,
218
+ let id = parseString(dict["id"]),
219
+ let label = parseString(dict["label"]) else { return nil }
220
+ let base64Icon = parseString(dict["base64_icon"])
221
+ let base64ActiveIcon = parseString(dict["base64_active_icon"]) ?? base64Icon
222
+ guard !id.isEmpty else { return nil }
223
+ return TabItemData(id: id, label: label, base64Icon: base64Icon, base64ActiveIcon: base64ActiveIcon)
224
+ }
225
+ }
226
+
227
+ private static func parseString(_ value: JSValue?) -> String? {
228
+ guard let value = value else { return nil }
229
+ if let s = value as? String { return s }
230
+ if let ns = value as? NSString { return String(ns) }
231
+ return nil
232
+ }
233
+ }
234
+
235
+ extension CapTabbar: UITabBarDelegate {
236
+ public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
237
+ let index = item.tag
238
+ guard index < tabItems.count else { return }
239
+ let tab = tabItems[index]
240
+ if selectedId != tab.id {
241
+ selectedId = tab.id
242
+ updateItemImages()
243
+ onTabSelected?(tab.id)
244
+ }
245
+ }
246
+ }
@@ -0,0 +1,75 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import UIKit
4
+
5
+ @objc(CapTabbarPlugin)
6
+ public class CapTabbarPlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "CapTabbarPlugin"
8
+ public let jsName = "CapTabbar"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "show", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "hide", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "setSelectedTab", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise)
14
+ ]
15
+ private let implementation = CapTabbar()
16
+
17
+ public override func load() {
18
+ implementation.setTabSelectedCallback { [weak self] tabId in
19
+ self?.notifyListeners("tabChange", data: ["tabId": tabId])
20
+ }
21
+ }
22
+
23
+ @objc func show(_ call: CAPPluginCall) {
24
+ guard let tabsArray = call.getArray("tabs"),
25
+ let selectedId = call.getString("selectedId") else {
26
+ call.reject("tabs and selectedId are required")
27
+ return
28
+ }
29
+
30
+ let tabs = CapTabbar.parseTabs(from: tabsArray)
31
+ guard !tabs.isEmpty else {
32
+ call.reject("At least one tab is required")
33
+ return
34
+ }
35
+
36
+ let labelColor = call.getString("label_color")
37
+ let labelColorActive = call.getString("label_color_active")
38
+ DispatchQueue.main.async { [weak self] in
39
+ guard let self = self,
40
+ let viewController = self.bridge?.viewController,
41
+ let view = viewController.view else {
42
+ call.reject("Could not find view to attach tab bar")
43
+ return
44
+ }
45
+ self.implementation.show(parent: view, tabs: tabs, selectedId: selectedId, labelColor: labelColor, labelColorActive: labelColorActive)
46
+ call.resolve()
47
+ }
48
+ }
49
+
50
+ @objc func hide(_ call: CAPPluginCall) {
51
+ DispatchQueue.main.async { [weak self] in
52
+ self?.implementation.hide()
53
+ call.resolve()
54
+ }
55
+ }
56
+
57
+ @objc func setSelectedTab(_ call: CAPPluginCall) {
58
+ guard let tabId = call.getString("tabId") else {
59
+ call.reject("tabId is required")
60
+ return
61
+ }
62
+ DispatchQueue.main.async { [weak self] in
63
+ self?.implementation.setSelectedTab(tabId: tabId)
64
+ call.resolve()
65
+ }
66
+ }
67
+
68
+ @objc func getState(_ call: CAPPluginCall) {
69
+ let state = implementation.getState()
70
+ call.resolve([
71
+ "visible": state.visible,
72
+ "activeTabId": state.activeTabId
73
+ ])
74
+ }
75
+ }
@@ -0,0 +1,13 @@
1
+ import XCTest
2
+ @testable import CapTabbarPlugin
3
+
4
+ class CapTabbarTests: XCTestCase {
5
+ func testParseTabs() {
6
+ let tabs = CapTabbar.parseTabs(from: [
7
+ ["id": "home", "label": "Home"]
8
+ ])
9
+ XCTAssertEqual(tabs.count, 1)
10
+ XCTAssertEqual(tabs[0].id, "home")
11
+ XCTAssertEqual(tabs[0].label, "Home")
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "capacitor-native-tabbar",
3
+ "version": "0.0.1",
4
+ "description": "Native tab bar for Capacitor apps — UITabBar on iOS, BottomNavigationView on Android, overlays the WebView",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapacitorNativeTabbar.podspec"
17
+ ],
18
+ "author": "Anton Seagull",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/AntonSeagull/capacitor-native-tabbar.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/AntonSeagull/capacitor-native-tabbar/issues"
26
+ },
27
+ "keywords": [
28
+ "capacitor",
29
+ "capacitor-plugin",
30
+ "ionic",
31
+ "native",
32
+ "tabbar",
33
+ "tab-bar",
34
+ "bottom-navigation",
35
+ "ios",
36
+ "android"
37
+ ],
38
+ "scripts": {
39
+ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
40
+ "verify:ios": "xcodebuild -scheme CapacitorNativeTabbar -destination generic/platform=iOS",
41
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
42
+ "verify:web": "npm run build",
43
+ "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
44
+ "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
45
+ "eslint": "eslint . --ext ts",
46
+ "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
47
+ "swiftlint": "node-swiftlint",
48
+ "docgen": "docgen --api CapTabbarPlugin --output-readme README.md --output-json dist/docs.json",
49
+ "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
50
+ "clean": "rimraf ./dist",
51
+ "watch": "tsc --watch",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "devDependencies": {
55
+ "@capacitor/android": "^8.0.0",
56
+ "@capacitor/core": "^8.0.0",
57
+ "@capacitor/docgen": "^0.3.1",
58
+ "@capacitor/ios": "^8.0.0",
59
+ "@ionic/eslint-config": "^0.4.0",
60
+ "@ionic/prettier-config": "^4.0.0",
61
+ "@ionic/swiftlint-config": "^2.0.0",
62
+ "eslint": "^8.57.1",
63
+ "prettier": "^3.6.2",
64
+ "prettier-plugin-java": "^2.7.7",
65
+ "rimraf": "^6.1.0",
66
+ "rollup": "^4.53.2",
67
+ "swiftlint": "^2.0.0",
68
+ "typescript": "^5.9.3"
69
+ },
70
+ "peerDependencies": {
71
+ "@capacitor/core": ">=8.0.0"
72
+ },
73
+ "prettier": "@ionic/prettier-config",
74
+ "swiftlint": "@ionic/swiftlint-config",
75
+ "eslintConfig": {
76
+ "extends": "@ionic/eslint-config/recommended"
77
+ },
78
+ "capacitor": {
79
+ "ios": {
80
+ "src": "ios"
81
+ },
82
+ "android": {
83
+ "src": "android"
84
+ }
85
+ }
86
+ }