@27appstudio/react-native-bottom-toolbar 0.0.0-stage → 0.1.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.
Files changed (34) hide show
  1. package/BottomToolbar.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +130 -2
  4. package/android/build.gradle +38 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/bottomtoolbar/BottomToolbarModule.kt +195 -0
  7. package/android/src/main/java/com/bottomtoolbar/BottomToolbarPackage.kt +30 -0
  8. package/android/src/main/res/layout/bottom_toolbar_icon_button.xml +5 -0
  9. package/android/src/main/res/layout/bottom_toolbar_text_button.xml +5 -0
  10. package/ios/BottomToolbar.h +5 -0
  11. package/ios/BottomToolbar.mm +157 -0
  12. package/lib/module/NativeBottomToolbar.js +5 -0
  13. package/lib/module/NativeBottomToolbar.js.map +1 -0
  14. package/lib/module/index.js +62 -0
  15. package/lib/module/index.js.map +1 -0
  16. package/lib/module/package.json +1 -0
  17. package/lib/module/react-navigation/index.js +56 -0
  18. package/lib/module/react-navigation/index.js.map +1 -0
  19. package/lib/module/types.js +4 -0
  20. package/lib/module/types.js.map +1 -0
  21. package/lib/typescript/package.json +1 -0
  22. package/lib/typescript/src/NativeBottomToolbar.d.ts +28 -0
  23. package/lib/typescript/src/NativeBottomToolbar.d.ts.map +1 -0
  24. package/lib/typescript/src/index.d.ts +28 -0
  25. package/lib/typescript/src/index.d.ts.map +1 -0
  26. package/lib/typescript/src/react-navigation/index.d.ts +16 -0
  27. package/lib/typescript/src/react-navigation/index.d.ts.map +1 -0
  28. package/lib/typescript/src/types.d.ts +33 -0
  29. package/lib/typescript/src/types.d.ts.map +1 -0
  30. package/package.json +169 -4
  31. package/src/NativeBottomToolbar.ts +32 -0
  32. package/src/index.tsx +80 -0
  33. package/src/react-navigation/index.ts +62 -0
  34. package/src/types.ts +32 -0
@@ -0,0 +1,20 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "BottomToolbar"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => min_ios_version_supported }
14
+ s.source = { :git => "https://github.com/27AppStudio/react-native-bottom-toolbar.git", :tag => "v#{s.version}" }
15
+
16
+ s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
+ s.private_header_files = "ios/**/*.h"
18
+
19
+ install_modules_dependencies(s)
20
+ end
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 27AppStudio
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,131 @@
1
- # Temporary Holding Version
1
+ # @27appstudio/react-native-bottom-toolbar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The platform's own bottom toolbar for React Native screens:
4
+
5
+ - **iOS**: UIKit's navigation-controller toolbar, so you get what the system draws. On iOS 26 that's Liquid Glass (a lone item becomes a glass circle, like the bin in Photos), and earlier versions get the classic toolbar. Your app follows each iOS redesign without changes.
6
+ - **Android**: Material 3 components. One button shows as a FAB; two or more show as the M3 Expressive floating toolbar. Both sit above the navigation bar.
7
+
8
+ | | iOS 26 | iOS 18 | Android |
9
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
10
+ | Several items | <img src="docs/images/ios26-several.png" width="220" alt="iOS 26: a glass plus button on the left, pencil and share grouped in a glass capsule on the right"> | <img src="docs/images/ios18-several.png" width="220" alt="iOS 18: plus on the left, pencil and share on the right of the classic toolbar"> | <img src="docs/images/android-several.png" width="220" alt="Android: plus, pencil and share in a Material 3 floating toolbar"> |
11
+ | One item, red `tintColor` | <img src="docs/images/ios26-single.png" width="220" alt="iOS 26: a red bin in a glass circle"> | <img src="docs/images/ios18-single.png" width="220" alt="iOS 18: a red bin at the trailing edge of the toolbar"> | <img src="docs/images/android-single.png" width="220" alt="Android: a Material 3 FAB with a red bin icon"> |
12
+
13
+ It's a TurboModule with no views of its own in JS, no Expo modules, and no config plugin.
14
+
15
+ Made by [27AppStudio](https://github.com/27AppStudio) and used in production in [Splitify](https://splitify.cc).
16
+
17
+ ## When to use something else
18
+
19
+ - **Expo Router**: use [`Stack.Toolbar`](https://docs.expo.dev/router/advanced/stack-toolbar/), which does the iOS side natively.
20
+ - **react-native-screens v5**: a per-screen `ios.toolbarItems` option is proposed in [#4690](https://github.com/software-mansion/react-native-screens/pull/4690). Once it ships in a stable release, prefer it for iOS.
21
+
22
+ This library fills the gap for React Navigation's `native-stack` on react-native-screens v4, and is the only one of the three with an Android side.
23
+
24
+ ## Installation
25
+
26
+ ```sh
27
+ npm install @27appstudio/react-native-bottom-toolbar
28
+ cd ios && pod install
29
+ ```
30
+
31
+ Requirements:
32
+
33
+ - React Native 0.82+ with the New Architecture
34
+ - **iOS**: the screen must be in a native stack (`@react-navigation/native-stack` or another UINavigationController); the JS stack has none.
35
+ - **Android**: nothing more. The library ships `com.google.android.material:material` 1.13. Material 3 apps use their own theme colors; AppCompat apps get the default Material 3 DayNight palette.
36
+ - **Expo**: works in development builds, not in Expo Go.
37
+
38
+ ## Usage
39
+
40
+ ### With React Navigation
41
+
42
+ ```tsx
43
+ import { useBottomToolbar } from '@27appstudio/react-native-bottom-toolbar/react-navigation';
44
+
45
+ function ExpenseScreen({
46
+ navigation,
47
+ }: NativeStackScreenProps<Params, 'Expense'>) {
48
+ useBottomToolbar(navigation, [
49
+ {
50
+ id: 'delete',
51
+ systemImage: 'trash', // SF Symbol (iOS)
52
+ androidIcon: 'ic_delete', // drawable resource (Android)
53
+ accessibilityLabel: 'Delete',
54
+ onPress: confirmDelete,
55
+ },
56
+ ]);
57
+ // ...
58
+ }
59
+ ```
60
+
61
+ The hook shows the toolbar once the push has landed (or right away on the stack's first screen), hides it when the screen loses focus, and brings it back when a modal over the screen closes.
62
+
63
+ ### Without React Navigation
64
+
65
+ ```ts
66
+ import { show, hide } from '@27appstudio/react-native-bottom-toolbar';
67
+
68
+ show(
69
+ [
70
+ { id: 'add', systemImage: 'plus', androidIcon: 'ic_add', onPress: add },
71
+ { type: 'flexibleSpace' },
72
+ {
73
+ id: 'share',
74
+ systemImage: 'square.and.arrow.up',
75
+ androidIcon: 'ic_share',
76
+ onPress: share,
77
+ },
78
+ ],
79
+ { tintColor: '#0A7D6E' }
80
+ );
81
+
82
+ hide();
83
+ ```
84
+
85
+ On iOS, `show` targets the visible navigation controller's top screen, and waits for a running push or pop to finish first.
86
+
87
+ ## API
88
+
89
+ ### `show(items, options?)`
90
+
91
+ Replaces the items of any toolbar already shown.
92
+
93
+ | Item | Fields |
94
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | Button (default) | `id` (required), `onPress`, `systemImage` (iOS SF Symbol), `androidIcon` (drawable name), `title` (used when there's no icon), `accessibilityLabel` (defaults to `title`), `disabled` |
96
+ | `{ type: 'flexibleSpace' }` | iOS: pushes the items apart. Android: ignored. |
97
+ | `{ type: 'fixedSpace', width? }` | iOS: a fixed gap; on iOS 26 it also splits glass groups. Android: ignored. |
98
+
99
+ When `items` contains no space, iOS puts the buttons at the trailing edge. When it contains no button at all (`[]` or spacers only), `show` hides the toolbar, as `hide()` does, so a screen can pass an empty array while it has nothing to offer.
100
+
101
+ `options.tintColor` sets the icon and title color. iOS defaults to the navigation bar's tint (matching the header buttons); Android defaults to the Material 3 theme.
102
+
103
+ ### `hide()`
104
+
105
+ ### `addPressListener(listener: (id: string) => void): () => void`
106
+
107
+ Called for every button tap, in addition to that button's own `onPress`.
108
+
109
+ ### `isAvailable: boolean`
110
+
111
+ `false` where the native module is missing (web, Expo Go). There, `show` and `hide` do nothing.
112
+
113
+ ### `useBottomToolbar(navigation, items, options?)`
114
+
115
+ Imported from `/react-navigation`. `items` and `options` are read each time the toolbar is shown, so passing a new array doesn't resubscribe. To change a toolbar that's already on screen, call `show` again.
116
+
117
+ ## Platform notes
118
+
119
+ - **iOS content insets**: UIKit adds the toolbar to the screen's safe area, so a `ScrollView` with `contentInsetAdjustmentBehavior="automatic"` scrolls clear of it.
120
+ - **Android content insets**: the FAB and toolbar float over your content. Leave about 88dp of bottom padding in scrolling content so the last row isn't covered.
121
+ - **Android icons**: add vector drawables to `android/app/src/main/res/drawable`. A button without a drawable shows its `title` as text in the toolbar; a FAB needs an icon.
122
+
123
+ ## Contributing
124
+
125
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
126
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
127
+ - [Code of conduct](CODE_OF_CONDUCT.md)
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,38 @@
1
+ ext.BottomToolbar = [
2
+ minSdkVersion: 24,
3
+ compileSdkVersion: 36
4
+ ]
5
+
6
+ ext.getExtOrDefault = { prop ->
7
+ if (rootProject.ext.has(prop)) {
8
+ return rootProject.ext.get(prop)
9
+ }
10
+
11
+ return BottomToolbar[prop]
12
+ }
13
+
14
+
15
+ apply plugin: "com.android.library"
16
+
17
+ if (project.extensions.findByName("kotlin") == null) {
18
+ apply plugin: "kotlin-android"
19
+ }
20
+
21
+
22
+ apply plugin: "com.facebook.react"
23
+
24
+ android {
25
+ namespace "com.bottomtoolbar"
26
+
27
+ compileSdkVersion getExtOrDefault("compileSdkVersion")
28
+
29
+ defaultConfig {
30
+ minSdkVersion getExtOrDefault("minSdkVersion")
31
+ }
32
+ }
33
+
34
+ dependencies {
35
+ implementation "com.facebook.react:react-android"
36
+ // 1.13 brings the Material 3 Expressive floating toolbar.
37
+ implementation "com.google.android.material:material:1.13.0"
38
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,195 @@
1
+ package com.bottomtoolbar
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.res.ColorStateList
6
+ import android.util.Log
7
+ import android.view.ContextThemeWrapper
8
+ import android.view.Gravity
9
+ import android.view.LayoutInflater
10
+ import android.view.View
11
+ import android.view.ViewGroup
12
+ import android.widget.FrameLayout
13
+ import android.widget.LinearLayout
14
+ import androidx.core.view.ViewCompat
15
+ import androidx.core.view.WindowInsetsCompat
16
+ import androidx.core.view.updateLayoutParams
17
+ import java.lang.ref.WeakReference
18
+ import com.facebook.react.bridge.ReactApplicationContext
19
+ import com.facebook.react.bridge.ReadableArray
20
+ import com.facebook.react.bridge.ReadableMap
21
+ import com.facebook.react.bridge.UiThreadUtil
22
+ import com.facebook.react.uimanager.PixelUtil.dpToPx
23
+ import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
24
+ import com.google.android.material.button.MaterialButton
25
+ import com.google.android.material.floatingactionbutton.FloatingActionButton
26
+ import com.google.android.material.floatingtoolbar.FloatingToolbarLayout
27
+ import com.google.android.material.R as MaterialR
28
+
29
+ /**
30
+ * The Android side of the native bottom toolbar: a Material 3 FAB at the bottom end
31
+ * for one button (where iOS puts a lone item), a Material 3 floating toolbar at the
32
+ * bottom center for more. Spacers are iOS-only and skipped. Both float over the
33
+ * activity's content, above the navigation bar, until `hide`.
34
+ */
35
+ class BottomToolbarModule(reactContext: ReactApplicationContext) :
36
+ NativeBottomToolbarSpec(reactContext) {
37
+
38
+ /** Weak, so a destroyed activity's view is not kept; the next show replaces it. */
39
+ private var shown: WeakReference<View>? = null
40
+
41
+ override fun show(items: ReadableArray, options: ReadableMap) {
42
+ val buttons = (0 until items.size())
43
+ .mapNotNull { items.getMap(it) }
44
+ .filter { it.getString("type") == "button" }
45
+ val tint = if (options.hasKey("tintColor")) options.getDouble("tintColor").toInt() else null
46
+
47
+ UiThreadUtil.runOnUiThread {
48
+ val activity = reactApplicationContext.currentActivity ?: return@runOnUiThread
49
+ val content = activity.findViewById<ViewGroup>(android.R.id.content)
50
+ ?: return@runOnUiThread
51
+ removeShown(animated = false)
52
+ if (buttons.isEmpty()) {
53
+ return@runOnUiThread
54
+ }
55
+ val context = materialContext(activity)
56
+ val view = if (buttons.size == 1) {
57
+ fab(context, buttons[0], tint)
58
+ } else {
59
+ floatingToolbar(context, buttons, tint).apply {
60
+ alpha = 0f
61
+ translationY = ENTER_OFFSET_DP.dpToPx()
62
+ animate().alpha(1f).translationY(0f).setDuration(ENTER_DURATION).start()
63
+ }
64
+ }
65
+ content.addView(view)
66
+ // The window's insets went out before the view was added: ask for them again, to
67
+ // sit above the navigation bar.
68
+ ViewCompat.requestApplyInsets(view)
69
+ shown = WeakReference(view)
70
+ }
71
+ }
72
+
73
+ override fun hide() {
74
+ UiThreadUtil.runOnUiThread { removeShown(animated = true) }
75
+ }
76
+
77
+ private fun removeShown(animated: Boolean) {
78
+ val view = shown?.get() ?: return
79
+ shown = null
80
+ val parent = view.parent as? ViewGroup ?: return
81
+ if (!animated) {
82
+ parent.removeView(view)
83
+ } else if (view is FloatingActionButton) {
84
+ view.hide(object : FloatingActionButton.OnVisibilityChangedListener() {
85
+ override fun onHidden(fab: FloatingActionButton) {
86
+ parent.removeView(fab)
87
+ }
88
+ })
89
+ } else {
90
+ view.animate()
91
+ .alpha(0f)
92
+ .translationY(view.height / 2f)
93
+ .setDuration(EXIT_DURATION)
94
+ .withEndAction { parent.removeView(view) }
95
+ .start()
96
+ }
97
+ }
98
+
99
+ /** The activity itself under a Material 3 theme, else wrapped in one (AppCompat apps). */
100
+ private fun materialContext(activity: Activity): Context {
101
+ val attributes = activity.obtainStyledAttributes(intArrayOf(MaterialR.attr.isMaterial3Theme))
102
+ val isMaterial3 = attributes.getBoolean(0, false)
103
+ attributes.recycle()
104
+ return if (isMaterial3) {
105
+ activity
106
+ } else {
107
+ ContextThemeWrapper(activity, MaterialR.style.Theme_Material3_DayNight_NoActionBar)
108
+ }
109
+ }
110
+
111
+ private fun fab(context: Context, item: ReadableMap, tint: Int?): FloatingActionButton {
112
+ val margin = MARGIN_DP.dpToPx().toInt()
113
+ return FloatingActionButton(context).apply {
114
+ icon(context, item)?.let { setImageResource(it) }
115
+ ?: Log.w(TAG, "No androidIcon drawable for \"${item.getString("id")}\"")
116
+ tint?.let { imageTintList = ColorStateList.valueOf(it) }
117
+ describe(this, item)
118
+ layoutParams = FrameLayout.LayoutParams(
119
+ ViewGroup.LayoutParams.WRAP_CONTENT,
120
+ ViewGroup.LayoutParams.WRAP_CONTENT,
121
+ Gravity.BOTTOM or Gravity.END,
122
+ ).apply { setMargins(margin, margin, margin, margin) }
123
+ // Above the navigation bar, as the floating toolbar places itself.
124
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
125
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
126
+ view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
127
+ bottomMargin = margin + bars.bottom
128
+ marginEnd = margin + bars.right
129
+ }
130
+ insets
131
+ }
132
+ // FloatingActionButton.show() animates only once laid out, so start hidden.
133
+ visibility = View.INVISIBLE
134
+ post { show() }
135
+ }
136
+ }
137
+
138
+ private fun floatingToolbar(
139
+ context: Context,
140
+ items: List<ReadableMap>,
141
+ tint: Int?,
142
+ ): FloatingToolbarLayout {
143
+ val inflater = LayoutInflater.from(context)
144
+ val row = LinearLayout(context).apply {
145
+ orientation = LinearLayout.HORIZONTAL
146
+ gravity = Gravity.CENTER_VERTICAL
147
+ }
148
+ for (item in items) {
149
+ val iconRes = icon(context, item)
150
+ val button: MaterialButton
151
+ if (iconRes != null) {
152
+ button = inflater.inflate(R.layout.bottom_toolbar_icon_button, row, false) as MaterialButton
153
+ button.setIconResource(iconRes)
154
+ tint?.let { button.iconTint = ColorStateList.valueOf(it) }
155
+ } else {
156
+ button = inflater.inflate(R.layout.bottom_toolbar_text_button, row, false) as MaterialButton
157
+ button.text = item.getString("title") ?: item.getString("id")
158
+ tint?.let { button.setTextColor(it) }
159
+ }
160
+ describe(button, item)
161
+ row.addView(button)
162
+ }
163
+ return FloatingToolbarLayout(context).apply {
164
+ addView(row)
165
+ layoutParams = FrameLayout.LayoutParams(
166
+ ViewGroup.LayoutParams.WRAP_CONTENT,
167
+ ViewGroup.LayoutParams.WRAP_CONTENT,
168
+ Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
169
+ ).apply {
170
+ bottomMargin = MARGIN_DP.dpToPx().toInt()
171
+ }
172
+ }
173
+ }
174
+
175
+ private fun describe(view: View, item: ReadableMap) {
176
+ val id = item.getString("id") ?: ""
177
+ view.contentDescription = item.getString("accessibilityLabel")
178
+ view.isEnabled = !(item.hasKey("disabled") && item.getBoolean("disabled"))
179
+ view.setOnClickListener { emitOnItemPress(id) }
180
+ }
181
+
182
+ /** The `androidIcon` drawable in the app's resources, or null. */
183
+ private fun icon(context: Context, item: ReadableMap): Int? =
184
+ ResourceDrawableIdHelper.getResourceDrawableId(context, item.getString("androidIcon"))
185
+ .takeIf { it != 0 }
186
+
187
+ companion object {
188
+ const val NAME = NativeBottomToolbarSpec.NAME
189
+ private const val TAG = "BottomToolbar"
190
+ private const val ENTER_DURATION = 250L
191
+ private const val EXIT_DURATION = 200L
192
+ private const val ENTER_OFFSET_DP = 24f
193
+ private const val MARGIN_DP = 16f
194
+ }
195
+ }
@@ -0,0 +1,30 @@
1
+ package com.bottomtoolbar
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class BottomToolbarPackage : BaseReactPackage() {
10
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
11
+ return if (name == BottomToolbarModule.NAME) {
12
+ BottomToolbarModule(reactContext)
13
+ } else {
14
+ null
15
+ }
16
+ }
17
+
18
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
19
+ mapOf(
20
+ BottomToolbarModule.NAME to ReactModuleInfo(
21
+ name = BottomToolbarModule.NAME,
22
+ className = BottomToolbarModule.NAME,
23
+ canOverrideExistingModule = false,
24
+ needsEagerInit = false,
25
+ isCxxModule = false,
26
+ isTurboModule = true
27
+ )
28
+ )
29
+ }
30
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
3
+ style="@style/Widget.Material3.FloatingToolbar.IconButton"
4
+ android:layout_width="wrap_content"
5
+ android:layout_height="wrap_content" />
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
3
+ style="@style/Widget.Material3.FloatingToolbar.TextButton"
4
+ android:layout_width="wrap_content"
5
+ android:layout_height="wrap_content" />
@@ -0,0 +1,5 @@
1
+ #import <BottomToolbarSpec/BottomToolbarSpec.h>
2
+
3
+ @interface BottomToolbar : NativeBottomToolbarSpecBase <NativeBottomToolbarSpec>
4
+
5
+ @end
@@ -0,0 +1,157 @@
1
+ #import "BottomToolbar.h"
2
+
3
+ #import <React/RCTConvert.h>
4
+ #import <React/RCTUtils.h>
5
+ #import <UIKit/UIKit.h>
6
+
7
+ /**
8
+ * UIKit's own bottom toolbar for a screen of a native stack, so its items are the
9
+ * system's: on iOS 26 a lone trailing item is drawn as a glass circle (as Photos'
10
+ * bin), before that as the classic toolbar. Its items belong to the navigation
11
+ * controller's top screen; the JS side shows it when that screen is reached and
12
+ * hides it on leave.
13
+ */
14
+ @implementation BottomToolbar {
15
+ __weak UIViewController *_owner;
16
+ /** The navigation bar's tint, as last read undimmed. */
17
+ UIColor *_barTint;
18
+ }
19
+
20
+ + (NSString *)moduleName
21
+ {
22
+ return @"BottomToolbar";
23
+ }
24
+
25
+ + (BOOL)requiresMainQueueSetup
26
+ {
27
+ return NO;
28
+ }
29
+
30
+ - (dispatch_queue_t)methodQueue
31
+ {
32
+ return dispatch_get_main_queue();
33
+ }
34
+
35
+ /** The innermost navigation controller on screen, skipping a modal being dismissed. */
36
+ static UINavigationController *VisibleNavigationController(void)
37
+ {
38
+ UIViewController *controller = RCTKeyWindow().rootViewController;
39
+ UINavigationController *navigation = nil;
40
+ while (controller != nil) {
41
+ UIViewController *presented = controller.presentedViewController;
42
+ if (presented != nil && !presented.isBeingDismissed) {
43
+ controller = presented;
44
+ } else if ([controller isKindOfClass:[UINavigationController class]]) {
45
+ navigation = (UINavigationController *)controller;
46
+ controller = navigation.topViewController;
47
+ } else {
48
+ controller = controller.childViewControllers.lastObject;
49
+ }
50
+ }
51
+ return navigation;
52
+ }
53
+
54
+ - (UIBarButtonItem *)barItemFor:(NSDictionary *)item
55
+ {
56
+ NSString *type = item[@"type"];
57
+ if ([type isEqualToString:@"flexibleSpace"]) {
58
+ return [UIBarButtonItem flexibleSpaceItem];
59
+ }
60
+ if ([type isEqualToString:@"fixedSpace"]) {
61
+ return [UIBarButtonItem fixedSpaceItemOfWidth:[item[@"width"] doubleValue]];
62
+ }
63
+
64
+ __weak BottomToolbar *weakSelf = self;
65
+ NSString *itemId = item[@"id"];
66
+ UIBarButtonItem *barItem = [[UIBarButtonItem alloc]
67
+ initWithPrimaryAction:[UIAction actionWithHandler:^(UIAction *action) {
68
+ [weakSelf emitOnItemPress:itemId];
69
+ }]];
70
+ NSString *systemImage = item[@"systemImage"];
71
+ UIImage *image = systemImage != nil ? [UIImage systemImageNamed:systemImage] : nil;
72
+ if (image != nil) {
73
+ barItem.image = image;
74
+ } else {
75
+ barItem.title = item[@"title"];
76
+ }
77
+ barItem.accessibilityLabel = item[@"accessibilityLabel"];
78
+ barItem.enabled = ![item[@"disabled"] boolValue];
79
+ return barItem;
80
+ }
81
+
82
+ - (void)show:(NSArray *)items options:(JS::NativeBottomToolbar::NativeToolbarOptions &)options
83
+ {
84
+ // Back on the screen that already had the toolbar (a modal on it was dismissed): use it.
85
+ // The visible navigation controller could still be the modal's, as JS refocuses this
86
+ // screen before UIKit starts the dismissal.
87
+ UIViewController *owner = _owner;
88
+ UINavigationController *navigation = owner.navigationController;
89
+ if (navigation == nil || navigation.topViewController != owner) {
90
+ owner = nil;
91
+ navigation = VisibleNavigationController();
92
+ }
93
+ if (navigation == nil) {
94
+ return;
95
+ }
96
+
97
+ NSMutableArray<UIBarButtonItem *> *barItems = [NSMutableArray array];
98
+ BOOL hasSpace = NO;
99
+ for (NSDictionary *item in items) {
100
+ hasSpace = hasSpace || ![item[@"type"] isEqualToString:@"button"];
101
+ [barItems addObject:[self barItemFor:item]];
102
+ }
103
+ // Without spacers the items go to the trailing edge, where iOS 26 draws a lone one
104
+ // as a glass circle.
105
+ if (!hasSpace) {
106
+ [barItems insertObject:[UIBarButtonItem flexibleSpaceItem] atIndex:0];
107
+ }
108
+ std::optional<double> tintColor = options.tintColor();
109
+ UIColor *tint = tintColor.has_value() ? [RCTConvert UIColor:@(tintColor.value())] : nil;
110
+
111
+ void (^apply)(void) = ^{
112
+ // A first show reads the top screen once any push has landed, so the items go to
113
+ // the screen now on top.
114
+ UIViewController *target = owner ?: navigation.topViewController;
115
+ // Each item's tint, as iOS 26 glass ignores the toolbar's. By default the header's
116
+ // own items' colour, not the system blue; read only while undimmed, as before iOS 26
117
+ // the bar still reports its dimmed grey when back from a modal.
118
+ if (navigation.navigationBar.tintAdjustmentMode != UIViewTintAdjustmentModeDimmed) {
119
+ self->_barTint = navigation.navigationBar.tintColor;
120
+ }
121
+ for (UIBarButtonItem *barItem in barItems) {
122
+ barItem.tintColor = tint ?: self->_barTint;
123
+ }
124
+ self->_owner = target;
125
+ [target setToolbarItems:barItems animated:NO];
126
+ [navigation setToolbarHidden:NO animated:YES];
127
+ };
128
+
129
+ id<UIViewControllerTransitionCoordinator> coordinator = navigation.transitionCoordinator;
130
+ if (coordinator != nil) {
131
+ [coordinator animateAlongsideTransition:nil
132
+ completion:^(id _Nonnull context) {
133
+ apply();
134
+ }];
135
+ } else {
136
+ apply();
137
+ }
138
+ }
139
+
140
+ - (void)hide
141
+ {
142
+ // The owner is kept (weakly) for the next `show`: a modal over it hides the toolbar,
143
+ // and its dismissal brings it back on the same screen.
144
+ UIViewController *owner = _owner;
145
+ UINavigationController *navigation =
146
+ owner.navigationController ?: VisibleNavigationController();
147
+ [owner setToolbarItems:nil animated:NO];
148
+ [navigation setToolbarHidden:YES animated:YES];
149
+ }
150
+
151
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
152
+ (const facebook::react::ObjCTurboModule::InitParams &)params
153
+ {
154
+ return std::make_shared<facebook::react::NativeBottomToolbarSpecJSI>(params);
155
+ }
156
+
157
+ @end
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+ export default TurboModuleRegistry.get('BottomToolbar');
5
+ //# sourceMappingURL=NativeBottomToolbar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["TurboModuleRegistry","get"],"sourceRoot":"../../src","sources":["NativeBottomToolbar.ts"],"mappings":";;AAEA,SAASA,mBAAmB,QAAQ,cAAc;AA6BlD,eAAeA,mBAAmB,CAACC,GAAG,CAAO,eAAe,CAAC","ignoreList":[]}
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ import { processColor } from 'react-native';
4
+ import NativeBottomToolbar from "./NativeBottomToolbar.js";
5
+ /** False where the native module is missing: web, Expo Go, an unlinked build. */
6
+ export const isAvailable = NativeBottomToolbar != null;
7
+ let buttons = [];
8
+
9
+ /**
10
+ * Shows the native bottom toolbar with `items`, replacing any shown before.
11
+ *
12
+ * - iOS: UIKit's own toolbar on the visible navigation controller (so the
13
+ * screen must be in a native stack). Without spacers the items sit at the
14
+ * trailing edge; on iOS 26 a lone item is drawn as a glass circle, as in
15
+ * Photos. Waits for a running push or pop, so the items land on the screen
16
+ * on top.
17
+ * - Android: a Material 3 FAB for one button, a floating toolbar for more,
18
+ * above the navigation bar. Spacers are ignored.
19
+ *
20
+ * Without any button (an empty array, or spacers only) it hides the toolbar, as
21
+ * `hide` does: on iOS an empty bar would still take the bottom band's touches and
22
+ * add its height to the scroll views' insets.
23
+ */
24
+ export function show(items, options = {}) {
25
+ if (!NativeBottomToolbar) {
26
+ return;
27
+ }
28
+ if (!items.some(isButton)) {
29
+ hide();
30
+ return;
31
+ }
32
+ buttons = items.filter(isButton);
33
+ const tintColor = processColor(options.tintColor);
34
+ NativeBottomToolbar.show(items.map(item => isButton(item) ? {
35
+ ...item,
36
+ accessibilityLabel: item.accessibilityLabel ?? item.title,
37
+ onPress: undefined,
38
+ type: 'button'
39
+ } : item), typeof tintColor === 'number' ? {
40
+ tintColor
41
+ } : {});
42
+ }
43
+
44
+ /** Hides the toolbar. */
45
+ export function hide() {
46
+ buttons = [];
47
+ NativeBottomToolbar?.hide();
48
+ }
49
+
50
+ /**
51
+ * Calls `listener` with the id of each tapped button, in addition to that
52
+ * button's own `onPress`. Returns the unsubscribe function.
53
+ */
54
+ export function addPressListener(listener) {
55
+ const subscription = NativeBottomToolbar?.onItemPress(listener);
56
+ return () => subscription?.remove();
57
+ }
58
+ function isButton(item) {
59
+ return item.type === undefined || item.type === 'button';
60
+ }
61
+ NativeBottomToolbar?.onItemPress(id => buttons.find(button => button.id === id)?.onPress?.());
62
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["processColor","NativeBottomToolbar","isAvailable","buttons","show","items","options","some","isButton","hide","filter","tintColor","map","item","accessibilityLabel","title","onPress","undefined","type","addPressListener","listener","subscription","onItemPress","remove","id","find","button"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAEA,SAASA,YAAY,QAAQ,cAAc;AAE3C,OAAOC,mBAAmB,MAAM,0BAAuB;AASvD;AACA,OAAO,MAAMC,WAAW,GAAGD,mBAAmB,IAAI,IAAI;AAEtD,IAAIE,OAAwB,GAAG,EAAE;;AAEjC;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,IAAIA,CAACC,KAAoB,EAAEC,OAAuB,GAAG,CAAC,CAAC,EAAE;EACvE,IAAI,CAACL,mBAAmB,EAAE;IACxB;EACF;EACA,IAAI,CAACI,KAAK,CAACE,IAAI,CAACC,QAAQ,CAAC,EAAE;IACzBC,IAAI,CAAC,CAAC;IACN;EACF;EACAN,OAAO,GAAGE,KAAK,CAACK,MAAM,CAACF,QAAQ,CAAC;EAChC,MAAMG,SAAS,GAAGX,YAAY,CAACM,OAAO,CAACK,SAAS,CAAC;EACjDV,mBAAmB,CAACG,IAAI,CACtBC,KAAK,CAACO,GAAG,CAAEC,IAAI,IACbL,QAAQ,CAACK,IAAI,CAAC,GACV;IACE,GAAGA,IAAI;IACPC,kBAAkB,EAAED,IAAI,CAACC,kBAAkB,IAAID,IAAI,CAACE,KAAK;IACzDC,OAAO,EAAEC,SAAS;IAClBC,IAAI,EAAE;EACR,CAAC,GACDL,IACN,CAAC,EACD,OAAOF,SAAS,KAAK,QAAQ,GAAG;IAAEA;EAAU,CAAC,GAAG,CAAC,CACnD,CAAC;AACH;;AAEA;AACA,OAAO,SAASF,IAAIA,CAAA,EAAG;EACrBN,OAAO,GAAG,EAAE;EACZF,mBAAmB,EAAEQ,IAAI,CAAC,CAAC;AAC7B;;AAEA;AACA;AACA;AACA;AACA,OAAO,SAASU,gBAAgBA,CAACC,QAA8B,EAAE;EAC/D,MAAMC,YAAY,GAAGpB,mBAAmB,EAAEqB,WAAW,CAACF,QAAQ,CAAC;EAC/D,OAAO,MAAMC,YAAY,EAAEE,MAAM,CAAC,CAAC;AACrC;AAEA,SAASf,QAAQA,CAACK,IAAiB,EAAyB;EAC1D,OAAOA,IAAI,CAACK,IAAI,KAAKD,SAAS,IAAIJ,IAAI,CAACK,IAAI,KAAK,QAAQ;AAC1D;AAEAjB,mBAAmB,EAAEqB,WAAW,CAAEE,EAAE,IAClCrB,OAAO,CAACsB,IAAI,CAAEC,MAAM,IAAKA,MAAM,CAACF,EAAE,KAAKA,EAAE,CAAC,EAAER,OAAO,GAAG,CACxD,CAAC","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { hide, show } from "../index.js";
5
+
6
+ /**
7
+ * Shows `items` in the native bottom toolbar while this screen is on top of a
8
+ * native stack (`@react-navigation/native-stack`: the JS stack has no
9
+ * UINavigationController for iOS to put it in).
10
+ *
11
+ * The toolbar belongs to the whole stack: it is shown once the push has landed
12
+ * (`transitionEnd`), or at once on the stack's first screen; again on
13
+ * returning from a modal (`focus`); and hidden on `blur`. `items` and
14
+ * `options` are read at show time, so a new array (a language change) needs
15
+ * no resubscribe; call `show` to update a toolbar already on screen.
16
+ */
17
+ export function useBottomToolbar(navigation, items, options) {
18
+ const latest = useRef({
19
+ items,
20
+ options
21
+ });
22
+ latest.current = {
23
+ items,
24
+ options
25
+ };
26
+ useEffect(() => {
27
+ // Back from a pop, both focus and transitionEnd show: the latter comes after
28
+ // the popped screen's unmount, whose `hide` would otherwise win.
29
+ const showLatest = () => show(latest.current.items, latest.current.options);
30
+ // A pushed screen mounts before its push starts, so showing now would put
31
+ // the items on the screen below; the first screen has no push to wait for.
32
+ let arrived = navigation.isFocused() && navigation.getState().index === 0;
33
+ if (arrived) {
34
+ showLatest();
35
+ }
36
+ const offTransitionEnd = navigation.addListener('transitionEnd', event => {
37
+ if (!event.data.closing) {
38
+ arrived = true;
39
+ showLatest();
40
+ }
41
+ });
42
+ const offFocus = navigation.addListener('focus', () => {
43
+ if (arrived) {
44
+ showLatest();
45
+ }
46
+ });
47
+ const offBlur = navigation.addListener('blur', hide);
48
+ return () => {
49
+ offTransitionEnd();
50
+ offFocus();
51
+ offBlur();
52
+ hide();
53
+ };
54
+ }, [navigation]);
55
+ }
56
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useEffect","useRef","hide","show","useBottomToolbar","navigation","items","options","latest","current","showLatest","arrived","isFocused","getState","index","offTransitionEnd","addListener","event","data","closing","offFocus","offBlur"],"sourceRoot":"../../../src","sources":["react-navigation/index.ts"],"mappings":";;AAIA,SAASA,SAAS,EAAEC,MAAM,QAAQ,OAAO;AAEzC,SAASC,IAAI,EAAEC,IAAI,QAAQ,aAAU;;AAErC;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,gBAAgBA,CAC9BC,UAAoD,EACpDC,KAAoB,EACpBC,OAAwB,EACxB;EACA,MAAMC,MAAM,GAAGP,MAAM,CAAC;IAAEK,KAAK;IAAEC;EAAQ,CAAC,CAAC;EACzCC,MAAM,CAACC,OAAO,GAAG;IAAEH,KAAK;IAAEC;EAAQ,CAAC;EAEnCP,SAAS,CAAC,MAAM;IACd;IACA;IACA,MAAMU,UAAU,GAAGA,CAAA,KAAMP,IAAI,CAACK,MAAM,CAACC,OAAO,CAACH,KAAK,EAAEE,MAAM,CAACC,OAAO,CAACF,OAAO,CAAC;IAC3E;IACA;IACA,IAAII,OAAO,GAAGN,UAAU,CAACO,SAAS,CAAC,CAAC,IAAIP,UAAU,CAACQ,QAAQ,CAAC,CAAC,CAACC,KAAK,KAAK,CAAC;IACzE,IAAIH,OAAO,EAAE;MACXD,UAAU,CAAC,CAAC;IACd;IAEA,MAAMK,gBAAgB,GAAGV,UAAU,CAACW,WAAW,CAC7C,eAAe,EACdC,KAAK,IAAK;MACT,IAAI,CAACA,KAAK,CAACC,IAAI,CAACC,OAAO,EAAE;QACvBR,OAAO,GAAG,IAAI;QACdD,UAAU,CAAC,CAAC;MACd;IACF,CACF,CAAC;IACD,MAAMU,QAAQ,GAAGf,UAAU,CAACW,WAAW,CAAC,OAAO,EAAE,MAAM;MACrD,IAAIL,OAAO,EAAE;QACXD,UAAU,CAAC,CAAC;MACd;IACF,CAAC,CAAC;IACF,MAAMW,OAAO,GAAGhB,UAAU,CAACW,WAAW,CAAC,MAAM,EAAEd,IAAI,CAAC;IAEpD,OAAO,MAAM;MACXa,gBAAgB,CAAC,CAAC;MAClBK,QAAQ,CAAC,CAAC;MACVC,OAAO,CAAC,CAAC;MACTnB,IAAI,CAAC,CAAC;IACR,CAAC;EACH,CAAC,EAAE,CAACG,UAAU,CAAC,CAAC;AAClB","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+
3
+ export {};
4
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../src","sources":["types.ts"],"mappings":"","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,28 @@
1
+ import type { CodegenTypes, TurboModule } from 'react-native';
2
+ type NativeToolbarItem = {
3
+ accessibilityLabel?: string;
4
+ /** Android: the name of a drawable resource, e.g. `ic_delete`. */
5
+ androidIcon?: string;
6
+ disabled?: boolean;
7
+ /** A button's; reported back through `onItemPress` when it is tapped. */
8
+ id?: string;
9
+ /** iOS: an SF Symbol name, e.g. `trash`. */
10
+ systemImage?: string;
11
+ title?: string;
12
+ /** `button`, `flexibleSpace` or `fixedSpace`. */
13
+ type: string;
14
+ /** A fixed space's width in points; the system's when left out. */
15
+ width?: CodegenTypes.Double;
16
+ };
17
+ type NativeToolbarOptions = {
18
+ /** A `processColor` value; left out for the platform's own. */
19
+ tintColor?: number;
20
+ };
21
+ export interface Spec extends TurboModule {
22
+ hide(): void;
23
+ readonly onItemPress: CodegenTypes.EventEmitter<string>;
24
+ show(items: NativeToolbarItem[], options: NativeToolbarOptions): void;
25
+ }
26
+ declare const _default: Spec | null | undefined;
27
+ export default _default;
28
+ //# sourceMappingURL=NativeBottomToolbar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeBottomToolbar.d.ts","sourceRoot":"","sources":["../../../src/NativeBottomToolbar.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAI9D,KAAK,iBAAiB,GAAG;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,KAAK,CAAC,EAAE,YAAY,CAAC,MAAM,CAAC;CAC7B,CAAC;AAEF,KAAK,oBAAoB,GAAG;IAC1B,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IACxD,IAAI,CAAC,KAAK,EAAE,iBAAiB,EAAE,EAAE,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAAC;CACvE;;AAED,wBAA8D"}
@@ -0,0 +1,28 @@
1
+ import type { ToolbarItem, ToolbarOptions } from './types.js';
2
+ export type { ToolbarButton, ToolbarItem, ToolbarOptions, ToolbarSpace, } from './types.js';
3
+ /** False where the native module is missing: web, Expo Go, an unlinked build. */
4
+ export declare const isAvailable: boolean;
5
+ /**
6
+ * Shows the native bottom toolbar with `items`, replacing any shown before.
7
+ *
8
+ * - iOS: UIKit's own toolbar on the visible navigation controller (so the
9
+ * screen must be in a native stack). Without spacers the items sit at the
10
+ * trailing edge; on iOS 26 a lone item is drawn as a glass circle, as in
11
+ * Photos. Waits for a running push or pop, so the items land on the screen
12
+ * on top.
13
+ * - Android: a Material 3 FAB for one button, a floating toolbar for more,
14
+ * above the navigation bar. Spacers are ignored.
15
+ *
16
+ * Without any button (an empty array, or spacers only) it hides the toolbar, as
17
+ * `hide` does: on iOS an empty bar would still take the bottom band's touches and
18
+ * add its height to the scroll views' insets.
19
+ */
20
+ export declare function show(items: ToolbarItem[], options?: ToolbarOptions): void;
21
+ /** Hides the toolbar. */
22
+ export declare function hide(): void;
23
+ /**
24
+ * Calls `listener` with the id of each tapped button, in addition to that
25
+ * button's own `onPress`. Returns the unsubscribe function.
26
+ */
27
+ export declare function addPressListener(listener: (id: string) => void): () => void | undefined;
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,WAAW,EAAE,cAAc,EAAE,MAAM,YAAS,CAAC;AAM1E,YAAY,EACV,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,GACb,MAAM,YAAS,CAAC;AAEjB,iFAAiF;AACjF,eAAO,MAAM,WAAW,SAA8B,CAAC;AAIvD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,IAAI,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE,OAAO,GAAE,cAAmB,QAuBtE;AAED,yBAAyB;AACzB,wBAAgB,IAAI,SAGnB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,0BAG9D"}
@@ -0,0 +1,16 @@
1
+ import type { ParamListBase } from '@react-navigation/native';
2
+ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
3
+ import type { ToolbarItem, ToolbarOptions } from '../types.js';
4
+ /**
5
+ * Shows `items` in the native bottom toolbar while this screen is on top of a
6
+ * native stack (`@react-navigation/native-stack`: the JS stack has no
7
+ * UINavigationController for iOS to put it in).
8
+ *
9
+ * The toolbar belongs to the whole stack: it is shown once the push has landed
10
+ * (`transitionEnd`), or at once on the stack's first screen; again on
11
+ * returning from a modal (`focus`); and hidden on `blur`. `items` and
12
+ * `options` are read at show time, so a new array (a language change) needs
13
+ * no resubscribe; call `show` to update a toolbar already on screen.
14
+ */
15
+ export declare function useBottomToolbar(navigation: NativeStackNavigationProp<ParamListBase>, items: ToolbarItem[], options?: ToolbarOptions): void;
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/react-navigation/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAChF,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAU,CAAC;AAM5D;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,yBAAyB,CAAC,aAAa,CAAC,EACpD,KAAK,EAAE,WAAW,EAAE,EACpB,OAAO,CAAC,EAAE,cAAc,QAuCzB"}
@@ -0,0 +1,33 @@
1
+ import type { ColorValue } from 'react-native';
2
+ export type ToolbarButton = {
3
+ /** Read by VoiceOver / TalkBack; defaults to `title`. */
4
+ accessibilityLabel?: string;
5
+ /** Android: the name of a drawable resource, e.g. `ic_delete`. */
6
+ androidIcon?: string;
7
+ disabled?: boolean;
8
+ /** Unique among the items; passed to `onPress` listeners. */
9
+ id: string;
10
+ onPress?: () => void;
11
+ /** iOS: an SF Symbol name, e.g. `trash`. */
12
+ systemImage?: string;
13
+ /** Shown when the platform has no icon for the item. */
14
+ title?: string;
15
+ type?: 'button';
16
+ };
17
+ export type ToolbarSpace = {
18
+ type: 'flexibleSpace';
19
+ }
20
+ /** `width` in points; the system's own when left out. */
21
+ | {
22
+ type: 'fixedSpace';
23
+ width?: number;
24
+ };
25
+ export type ToolbarItem = ToolbarButton | ToolbarSpace;
26
+ export type ToolbarOptions = {
27
+ /**
28
+ * Icon and title color. iOS defaults to the navigation bar's tint (as the
29
+ * header's own buttons), Android to the Material 3 theme.
30
+ */
31
+ tintColor?: ColorValue;
32
+ };
33
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,MAAM,MAAM,aAAa,GAAG;IAC1B,yDAAyD;IACzD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE;AAC3B,yDAAyD;GACvD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3C,MAAM,MAAM,WAAW,GAAG,aAAa,GAAG,YAAY,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG;IAC3B;;;OAGG;IACH,SAAS,CAAC,EAAE,UAAU,CAAC;CACxB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,171 @@
1
1
  {
2
2
  "name": "@27appstudio/react-native-bottom-toolbar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Native bottom toolbar for React Native: UIKit's toolbar on iOS (Liquid Glass on iOS 26) and a Material 3 floating toolbar on Android.",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "react-native-bottom-toolbar-source": "./src/index.tsx",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./react-navigation": {
14
+ "react-native-bottom-toolbar-source": "./src/react-navigation/index.ts",
15
+ "types": "./lib/typescript/src/react-navigation/index.d.ts",
16
+ "default": "./lib/module/react-navigation/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "lib",
23
+ "android",
24
+ "ios",
25
+ "*.podspec",
26
+ "!ios/build",
27
+ "!android/build",
28
+ "!android/gradle",
29
+ "!android/gradlew",
30
+ "!android/gradlew.bat",
31
+ "!android/local.properties",
32
+ "!**/__tests__",
33
+ "!**/__fixtures__",
34
+ "!**/__mocks__",
35
+ "!**/.*"
36
+ ],
37
+ "scripts": {
38
+ "example": "yarn workspace react-native-bottom-toolbar-example",
39
+ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
40
+ "prepare": "bob build",
41
+ "typecheck": "tsc",
42
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
43
+ "release": "release-it --only-version"
44
+ },
45
+ "keywords": [
46
+ "react-native",
47
+ "ios",
48
+ "android",
49
+ "toolbar",
50
+ "liquid-glass",
51
+ "material3",
52
+ "fab",
53
+ "turbo-module",
54
+ "react-navigation"
55
+ ],
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/27AppStudio/react-native-bottom-toolbar.git"
59
+ },
60
+ "author": "27AppStudio <hello@27appstudio.com> (https://github.com/27AppStudio)",
61
+ "license": "MIT",
62
+ "bugs": {
63
+ "url": "https://github.com/27AppStudio/react-native-bottom-toolbar/issues"
64
+ },
65
+ "homepage": "https://github.com/27AppStudio/react-native-bottom-toolbar#readme",
66
+ "publishConfig": {
67
+ "registry": "https://registry.npmjs.org/",
68
+ "access": "public"
69
+ },
70
+ "devDependencies": {
71
+ "@eslint/compat": "^2.1.1",
72
+ "@eslint/eslintrc": "^3.3.7",
73
+ "@eslint/js": "^9.39.5",
74
+ "@react-native/babel-preset": "0.84.1",
75
+ "@react-native/eslint-config": "0.84.1",
76
+ "@react-navigation/native": "^7.5.1",
77
+ "@react-navigation/native-stack": "^7.21.0",
78
+ "@release-it/conventional-changelog": "^12.0.2",
79
+ "@types/react": "^19.2.0",
80
+ "del-cli": "^8.0.1",
81
+ "eslint": "^9.39.5",
82
+ "eslint-config-prettier": "^10.1.8",
83
+ "eslint-plugin-ft-flow": "^3.0.11",
84
+ "eslint-plugin-prettier": "^5.5.7",
85
+ "prettier": "^3.9.10",
86
+ "react": "19.2.3",
87
+ "react-native": "0.84.1",
88
+ "react-native-builder-bob": "^0.44.1",
89
+ "react-native-safe-area-context": "~5.10.0",
90
+ "react-native-screens": "4.30.0",
91
+ "release-it": "^21.1.1",
92
+ "turbo": "^2.11.10",
93
+ "typescript": "^6.0.3"
94
+ },
95
+ "peerDependencies": {
96
+ "@react-navigation/native": ">=7",
97
+ "@react-navigation/native-stack": ">=7",
98
+ "react": "*",
99
+ "react-native": ">=0.82"
100
+ },
101
+ "workspaces": [
102
+ "example"
103
+ ],
104
+ "packageManager": "yarn@4.18.1",
105
+ "react-native-builder-bob": {
106
+ "source": "src",
107
+ "output": "lib",
108
+ "targets": [
109
+ [
110
+ "module",
111
+ {
112
+ "esm": true
113
+ }
114
+ ],
115
+ [
116
+ "typescript",
117
+ {
118
+ "project": "tsconfig.build.json"
119
+ }
120
+ ]
121
+ ]
122
+ },
123
+ "codegenConfig": {
124
+ "name": "BottomToolbarSpec",
125
+ "type": "modules",
126
+ "jsSrcsDir": "src",
127
+ "ios": {
128
+ "modulesProvider": {
129
+ "BottomToolbar": "BottomToolbar"
130
+ }
131
+ },
132
+ "android": {
133
+ "javaPackageName": "com.bottomtoolbar"
134
+ }
135
+ },
136
+ "prettier": {
137
+ "quoteProps": "consistent",
138
+ "singleQuote": true,
139
+ "tabWidth": 2,
140
+ "trailingComma": "es5",
141
+ "useTabs": false
142
+ },
143
+ "peerDependenciesMeta": {
144
+ "@react-navigation/native": {
145
+ "optional": true
146
+ },
147
+ "@react-navigation/native-stack": {
148
+ "optional": true
149
+ }
150
+ },
151
+ "release-it": {
152
+ "git": {
153
+ "commitMessage": "chore: release ${version}",
154
+ "tagName": "v${version}"
155
+ },
156
+ "npm": {
157
+ "publish": false
158
+ },
159
+ "github": {
160
+ "release": false
161
+ },
162
+ "plugins": {
163
+ "@release-it/conventional-changelog": {
164
+ "preset": {
165
+ "name": "angular"
166
+ },
167
+ "infile": "CHANGELOG.md"
168
+ }
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,32 @@
1
+ import type { CodegenTypes, TurboModule } from 'react-native';
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+
5
+ type NativeToolbarItem = {
6
+ accessibilityLabel?: string;
7
+ /** Android: the name of a drawable resource, e.g. `ic_delete`. */
8
+ androidIcon?: string;
9
+ disabled?: boolean;
10
+ /** A button's; reported back through `onItemPress` when it is tapped. */
11
+ id?: string;
12
+ /** iOS: an SF Symbol name, e.g. `trash`. */
13
+ systemImage?: string;
14
+ title?: string;
15
+ /** `button`, `flexibleSpace` or `fixedSpace`. */
16
+ type: string;
17
+ /** A fixed space's width in points; the system's when left out. */
18
+ width?: CodegenTypes.Double;
19
+ };
20
+
21
+ type NativeToolbarOptions = {
22
+ /** A `processColor` value; left out for the platform's own. */
23
+ tintColor?: number;
24
+ };
25
+
26
+ export interface Spec extends TurboModule {
27
+ hide(): void;
28
+ readonly onItemPress: CodegenTypes.EventEmitter<string>;
29
+ show(items: NativeToolbarItem[], options: NativeToolbarOptions): void;
30
+ }
31
+
32
+ export default TurboModuleRegistry.get<Spec>('BottomToolbar');
package/src/index.tsx ADDED
@@ -0,0 +1,80 @@
1
+ import type { ToolbarButton, ToolbarItem, ToolbarOptions } from './types';
2
+
3
+ import { processColor } from 'react-native';
4
+
5
+ import NativeBottomToolbar from './NativeBottomToolbar';
6
+
7
+ export type {
8
+ ToolbarButton,
9
+ ToolbarItem,
10
+ ToolbarOptions,
11
+ ToolbarSpace,
12
+ } from './types';
13
+
14
+ /** False where the native module is missing: web, Expo Go, an unlinked build. */
15
+ export const isAvailable = NativeBottomToolbar != null;
16
+
17
+ let buttons: ToolbarButton[] = [];
18
+
19
+ /**
20
+ * Shows the native bottom toolbar with `items`, replacing any shown before.
21
+ *
22
+ * - iOS: UIKit's own toolbar on the visible navigation controller (so the
23
+ * screen must be in a native stack). Without spacers the items sit at the
24
+ * trailing edge; on iOS 26 a lone item is drawn as a glass circle, as in
25
+ * Photos. Waits for a running push or pop, so the items land on the screen
26
+ * on top.
27
+ * - Android: a Material 3 FAB for one button, a floating toolbar for more,
28
+ * above the navigation bar. Spacers are ignored.
29
+ *
30
+ * Without any button (an empty array, or spacers only) it hides the toolbar, as
31
+ * `hide` does: on iOS an empty bar would still take the bottom band's touches and
32
+ * add its height to the scroll views' insets.
33
+ */
34
+ export function show(items: ToolbarItem[], options: ToolbarOptions = {}) {
35
+ if (!NativeBottomToolbar) {
36
+ return;
37
+ }
38
+ if (!items.some(isButton)) {
39
+ hide();
40
+ return;
41
+ }
42
+ buttons = items.filter(isButton);
43
+ const tintColor = processColor(options.tintColor);
44
+ NativeBottomToolbar.show(
45
+ items.map((item) =>
46
+ isButton(item)
47
+ ? {
48
+ ...item,
49
+ accessibilityLabel: item.accessibilityLabel ?? item.title,
50
+ onPress: undefined,
51
+ type: 'button',
52
+ }
53
+ : item
54
+ ),
55
+ typeof tintColor === 'number' ? { tintColor } : {}
56
+ );
57
+ }
58
+
59
+ /** Hides the toolbar. */
60
+ export function hide() {
61
+ buttons = [];
62
+ NativeBottomToolbar?.hide();
63
+ }
64
+
65
+ /**
66
+ * Calls `listener` with the id of each tapped button, in addition to that
67
+ * button's own `onPress`. Returns the unsubscribe function.
68
+ */
69
+ export function addPressListener(listener: (id: string) => void) {
70
+ const subscription = NativeBottomToolbar?.onItemPress(listener);
71
+ return () => subscription?.remove();
72
+ }
73
+
74
+ function isButton(item: ToolbarItem): item is ToolbarButton {
75
+ return item.type === undefined || item.type === 'button';
76
+ }
77
+
78
+ NativeBottomToolbar?.onItemPress((id) =>
79
+ buttons.find((button) => button.id === id)?.onPress?.()
80
+ );
@@ -0,0 +1,62 @@
1
+ import type { ParamListBase } from '@react-navigation/native';
2
+ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
3
+ import type { ToolbarItem, ToolbarOptions } from '../types';
4
+
5
+ import { useEffect, useRef } from 'react';
6
+
7
+ import { hide, show } from '../index';
8
+
9
+ /**
10
+ * Shows `items` in the native bottom toolbar while this screen is on top of a
11
+ * native stack (`@react-navigation/native-stack`: the JS stack has no
12
+ * UINavigationController for iOS to put it in).
13
+ *
14
+ * The toolbar belongs to the whole stack: it is shown once the push has landed
15
+ * (`transitionEnd`), or at once on the stack's first screen; again on
16
+ * returning from a modal (`focus`); and hidden on `blur`. `items` and
17
+ * `options` are read at show time, so a new array (a language change) needs
18
+ * no resubscribe; call `show` to update a toolbar already on screen.
19
+ */
20
+ export function useBottomToolbar(
21
+ navigation: NativeStackNavigationProp<ParamListBase>,
22
+ items: ToolbarItem[],
23
+ options?: ToolbarOptions
24
+ ) {
25
+ const latest = useRef({ items, options });
26
+ latest.current = { items, options };
27
+
28
+ useEffect(() => {
29
+ // Back from a pop, both focus and transitionEnd show: the latter comes after
30
+ // the popped screen's unmount, whose `hide` would otherwise win.
31
+ const showLatest = () => show(latest.current.items, latest.current.options);
32
+ // A pushed screen mounts before its push starts, so showing now would put
33
+ // the items on the screen below; the first screen has no push to wait for.
34
+ let arrived = navigation.isFocused() && navigation.getState().index === 0;
35
+ if (arrived) {
36
+ showLatest();
37
+ }
38
+
39
+ const offTransitionEnd = navigation.addListener(
40
+ 'transitionEnd',
41
+ (event) => {
42
+ if (!event.data.closing) {
43
+ arrived = true;
44
+ showLatest();
45
+ }
46
+ }
47
+ );
48
+ const offFocus = navigation.addListener('focus', () => {
49
+ if (arrived) {
50
+ showLatest();
51
+ }
52
+ });
53
+ const offBlur = navigation.addListener('blur', hide);
54
+
55
+ return () => {
56
+ offTransitionEnd();
57
+ offFocus();
58
+ offBlur();
59
+ hide();
60
+ };
61
+ }, [navigation]);
62
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { ColorValue } from 'react-native';
2
+
3
+ export type ToolbarButton = {
4
+ /** Read by VoiceOver / TalkBack; defaults to `title`. */
5
+ accessibilityLabel?: string;
6
+ /** Android: the name of a drawable resource, e.g. `ic_delete`. */
7
+ androidIcon?: string;
8
+ disabled?: boolean;
9
+ /** Unique among the items; passed to `onPress` listeners. */
10
+ id: string;
11
+ onPress?: () => void;
12
+ /** iOS: an SF Symbol name, e.g. `trash`. */
13
+ systemImage?: string;
14
+ /** Shown when the platform has no icon for the item. */
15
+ title?: string;
16
+ type?: 'button';
17
+ };
18
+
19
+ export type ToolbarSpace =
20
+ | { type: 'flexibleSpace' }
21
+ /** `width` in points; the system's own when left out. */
22
+ | { type: 'fixedSpace'; width?: number };
23
+
24
+ export type ToolbarItem = ToolbarButton | ToolbarSpace;
25
+
26
+ export type ToolbarOptions = {
27
+ /**
28
+ * Icon and title color. iOS defaults to the navigation bar's tint (as the
29
+ * header's own buttons), Android to the Material 3 theme.
30
+ */
31
+ tintColor?: ColorValue;
32
+ };