@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.
- package/BottomToolbar.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +130 -2
- package/android/build.gradle +38 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/bottomtoolbar/BottomToolbarModule.kt +195 -0
- package/android/src/main/java/com/bottomtoolbar/BottomToolbarPackage.kt +30 -0
- package/android/src/main/res/layout/bottom_toolbar_icon_button.xml +5 -0
- package/android/src/main/res/layout/bottom_toolbar_text_button.xml +5 -0
- package/ios/BottomToolbar.h +5 -0
- package/ios/BottomToolbar.mm +157 -0
- package/lib/module/NativeBottomToolbar.js +5 -0
- package/lib/module/NativeBottomToolbar.js.map +1 -0
- package/lib/module/index.js +62 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/react-navigation/index.js +56 -0
- package/lib/module/react-navigation/index.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeBottomToolbar.d.ts +28 -0
- package/lib/typescript/src/NativeBottomToolbar.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +28 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/react-navigation/index.d.ts +16 -0
- package/lib/typescript/src/react-navigation/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +33 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +169 -4
- package/src/NativeBottomToolbar.ts +32 -0
- package/src/index.tsx +80 -0
- package/src/react-navigation/index.ts +62 -0
- 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
|
-
#
|
|
1
|
+
# @27appstudio/react-native-bottom-toolbar
|
|
2
2
|
|
|
3
|
-
|
|
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,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,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 @@
|
|
|
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 @@
|
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
};
|