react-native-unified-action-sheet 0.1.0
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/CHANGELOG.md +41 -0
- package/LICENSE +20 -0
- package/README.md +167 -0
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +134 -0
- package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +159 -0
- package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +214 -0
- package/android/src/main/java/com/unifiedactionsheet/SheetTheming.kt +56 -0
- package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +126 -0
- package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetPackage.kt +30 -0
- package/ios/UnifiedActionSheet.h +5 -0
- package/ios/UnifiedActionSheet.mm +96 -0
- package/ios/UnifiedActionSheetImpl.swift +240 -0
- package/jest/index.d.ts +12 -0
- package/jest/index.js +46 -0
- package/lib/commonjs/NativeUnifiedActionSheet.js +9 -0
- package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -0
- package/lib/commonjs/action-sheet-options.interface.js +2 -0
- package/lib/commonjs/action-sheet-options.interface.js.map +1 -0
- package/lib/commonjs/index.js +104 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/NativeUnifiedActionSheet.js +5 -0
- package/lib/module/NativeUnifiedActionSheet.js.map +1 -0
- package/lib/module/action-sheet-options.interface.js +2 -0
- package/lib/module/action-sheet-options.interface.js.map +1 -0
- package/lib/module/index.js +97 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +29 -0
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +29 -0
- package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +6 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +29 -0
- package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -0
- package/lib/typescript/module/src/action-sheet-options.interface.d.ts +29 -0
- package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +6 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/package.json +155 -0
- package/react-native-unified-action-sheet.podspec +23 -0
- package/src/NativeUnifiedActionSheet.ts +29 -0
- package/src/action-sheet-options.interface.ts +43 -0
- package/src/index.tsx +131 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0]
|
|
11
|
+
|
|
12
|
+
Initial release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `showActionSheetWithOptions(options)` — presents a native `UIAlertController`
|
|
17
|
+
on iOS and a native `AppCompatDialog` on Android through one TurboModule, and
|
|
18
|
+
resolves with the tapped button's index.
|
|
19
|
+
- Object-shaped buttons: `{ label, style?: 'cancel' | 'destructive', disabled?, onPress? }`.
|
|
20
|
+
`onPress` runs when that button resolves the sheet, so callers rarely match on
|
|
21
|
+
an index.
|
|
22
|
+
- `dismissActionSheet()` and `dismissAllActionSheets()` for closing sheets from
|
|
23
|
+
code; both resolve the affected promises with `undefined`.
|
|
24
|
+
- `presentationStyle: 'centered' | 'anchored'`. An `'anchored'` sheet attaches to
|
|
25
|
+
the `anchor` ref, which the library measures in JS — an iPad popover on iOS, a
|
|
26
|
+
menu-style popup on Android.
|
|
27
|
+
- Theming options: `userInterfaceStyle`, `tintColor`, `cancelButtonTintColor`,
|
|
28
|
+
`destructiveColor`, and the Android-only `buttonTextAlignment` and
|
|
29
|
+
`anchorAlignment`.
|
|
30
|
+
- A Jest mock at the `react-native-unified-action-sheet/jest` subpath, driven by
|
|
31
|
+
`setNextButtonIndex()` so the pressed button's `onPress` still runs.
|
|
32
|
+
- CommonJS and ES module builds, plus type definitions for both.
|
|
33
|
+
|
|
34
|
+
### Notes
|
|
35
|
+
|
|
36
|
+
- Supports React Native 0.81 on both architectures through the latest release.
|
|
37
|
+
- No `com.google.android.material` dependency, and no runtime dependencies —
|
|
38
|
+
`react` and `react-native` are peers.
|
|
39
|
+
|
|
40
|
+
[unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...HEAD
|
|
41
|
+
[0.1.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentyn Halkin
|
|
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
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# react-native-unified-action-sheet
|
|
2
|
+
|
|
3
|
+
Unified action sheet API for React Native: a native `UIAlertController` on iOS, a native `AppCompatDialog` on Android.
|
|
4
|
+
|
|
5
|
+
| Android | Android (dark mode) | iOS |
|
|
6
|
+
| :---: | :---: | :---: |
|
|
7
|
+
| <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/android-demo.gif" width="280" alt="Android demo" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/android-demo-dark-mode.gif" width="280" alt="Android dark-mode demo — following the system dark theme" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/ios-demo.gif" width="280" alt="iOS demo" /> |
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/ipad-demo.gif" width="420" alt="iPad demo — the sheet as a popover anchored to the button that opened it" />
|
|
11
|
+
<br />
|
|
12
|
+
<em>iPad: the same sheet presented as a popover, anchored to the view that opened it.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
- **Fully native, always on top.** The sheet gets its own platform window, so it renders above your entire React Native view tree — including above an already-open [`Modal`](https://reactnative.dev/docs/modal). JS-rendered action sheets live *inside* the component tree, where `overflow`, `zIndex`, a transform or an open modal can clip or bury them.
|
|
16
|
+
- **One API, native on both platforms**, so the same options behave the same way.
|
|
17
|
+
- Works on React Native **0.81 (old and new architecture)** through the **latest** release.
|
|
18
|
+
- **No extra native dependencies** on either platform, and zero runtime dependencies (`react` / `react-native` peers only).
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install react-native-unified-action-sheet
|
|
24
|
+
# or
|
|
25
|
+
yarn add react-native-unified-action-sheet
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Autolinking handles the rest on both platforms. In Expo apps, use a development build (`npx expo run:ios` / `npx expo run:android`) — custom native modules do not work in **Expo Go**.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { showActionSheetWithOptions } from 'react-native-unified-action-sheet';
|
|
34
|
+
|
|
35
|
+
await showActionSheetWithOptions({
|
|
36
|
+
title: 'Delete item?',
|
|
37
|
+
message: 'This action cannot be undone.',
|
|
38
|
+
options: [
|
|
39
|
+
{ label: 'Delete', style: 'destructive', onPress: deleteItem },
|
|
40
|
+
{ label: 'Archive', onPress: archiveItem },
|
|
41
|
+
{ label: 'Unavailable', disabled: true },
|
|
42
|
+
{ label: 'Cancel', style: 'cancel' },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each button is an object. `style` marks the two native roles, which are mutually exclusive; `disabled` is independent of it; and `onPress` runs when that button resolves the sheet, so reordering buttons cannot send one down another's branch. The call also resolves with the tapped index, and the two never disagree:
|
|
48
|
+
|
|
49
|
+
| | promise resolves with | `onPress` runs |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Tapping a button | its index | that button's |
|
|
52
|
+
| Backdrop tap, back button, swipe down | the `'cancel'` button's index, or `-1` if there is none | the cancel button's, if there is one |
|
|
53
|
+
| `dismissActionSheet()` | `undefined` | nothing |
|
|
54
|
+
|
|
55
|
+
The promise never rejects on its own — only if one of your own `onPress` handlers throws.
|
|
56
|
+
|
|
57
|
+
### Anchored sheets
|
|
58
|
+
|
|
59
|
+
`presentationStyle: 'anchored'` attaches the sheet to a view. Pass the ref itself; the library measures it and sends only the resulting rectangle across:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const anchorRef = useRef<View>(null);
|
|
63
|
+
|
|
64
|
+
<Pressable
|
|
65
|
+
ref={anchorRef}
|
|
66
|
+
onPress={() =>
|
|
67
|
+
showActionSheetWithOptions({
|
|
68
|
+
options: [...],
|
|
69
|
+
presentationStyle: 'anchored',
|
|
70
|
+
anchor: anchorRef,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
/>;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Anything with a `measureInWindow` method works, which is every React Native host component ref. On iOS the anchor only applies on iPad, where the sheet becomes a popover; iPhone always presents from the bottom.
|
|
77
|
+
|
|
78
|
+
### Dismissing from code
|
|
79
|
+
|
|
80
|
+
`dismissActionSheet()` closes the most recently opened sheet, `dismissAllActionSheets()` closes every open one. Both are no-ops when nothing is open, and the dismissed sheets resolve with `undefined`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import {
|
|
84
|
+
dismissActionSheet,
|
|
85
|
+
dismissAllActionSheets,
|
|
86
|
+
} from 'react-native-unified-action-sheet';
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Options
|
|
90
|
+
|
|
91
|
+
| Option | Type | iOS | Android | Description |
|
|
92
|
+
| --- | --- | :---: | :---: | --- |
|
|
93
|
+
| `options` | `ActionSheetButtonInterface[]` | ✅ | ✅ | The buttons, in order: `{ label, style?, disabled?, onPress? }`. |
|
|
94
|
+
| `style` (per button) | `'cancel' \| 'destructive'?` | ✅ | ✅ | `'cancel'` renders a separated cancel row on Android and resolves on backdrop tap / back; `'destructive'` renders in the destructive color. Only the first `'cancel'` counts. |
|
|
95
|
+
| `disabled` (per button) | `boolean?` | ✅ | ✅ | Renders the row dimmed and unresponsive to taps. |
|
|
96
|
+
| `onPress` (per button) | `() => void?` | ✅ | ✅ | Runs when this button resolves the sheet. |
|
|
97
|
+
| `title` | `string?` | ✅ | ✅ | Sheet title. |
|
|
98
|
+
| `message` | `string?` | ✅ | ✅ | Secondary text under the title. |
|
|
99
|
+
| `presentationStyle` | `'centered' \| 'anchored'` | ✅ | ✅ | `'centered'` is a centered dialog; `'anchored'` attaches to `anchor` (an iPad popover on iOS). **Defaults differ**: Android defaults to `'centered'`, iOS to the standard action sheet. |
|
|
100
|
+
| `anchor` | `ActionSheetAnchorInterface?` | ✅ | ✅ | The view to attach to — a ref, or anything with `measureInWindow`. Without a measurable anchor an `'anchored'` sheet falls back to a centered dialog. |
|
|
101
|
+
| `anchorAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of an `'anchored'` popup relative to its anchor. `'start'` (default) aligns leading edges, flipping in RTL. |
|
|
102
|
+
| `userInterfaceStyle` | `'light' \| 'dark'` | ✅ | ✅ | Forces the appearance; defaults to following the system setting. |
|
|
103
|
+
| `tintColor` | `string?` | ✅ | ✅ | Text color of non-destructive buttons. |
|
|
104
|
+
| `cancelButtonTintColor` | `string?` | ✅ | ✅ | Text color of the cancel button; overrides `tintColor` for that row. |
|
|
105
|
+
| `destructiveColor` | `string?` | ✅ | ✅ | Overrides the destructive row color (Android's palette error color, iOS system red). |
|
|
106
|
+
| `buttonTextAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of button labels. Defaults to `'start'`, which follows layout direction. |
|
|
107
|
+
|
|
108
|
+
The options types are exported for typing your own wrappers:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import type {
|
|
112
|
+
ActionSheetOptionsInterface, // what showActionSheetWithOptions accepts
|
|
113
|
+
ActionSheetCommonOptionsInterface,
|
|
114
|
+
ActionSheetAndroidOptionsInterface,
|
|
115
|
+
ActionSheetButtonInterface, // a single button
|
|
116
|
+
ActionSheetAnchorInterface, // anything with measureInWindow
|
|
117
|
+
} from 'react-native-unified-action-sheet';
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Platform notes
|
|
121
|
+
|
|
122
|
+
- **Which gestures dismiss differs.** On iOS a sheet can only be tapped away if it has a `'cancel'` button, and a `'centered'` one never can — UIKit treats it as strictly modal. Android's centered dialog always cancels on a backdrop tap. Give a sheet a cancel button if you want that gesture everywhere.
|
|
123
|
+
- **On iPad, a popover hides the cancel row**, since tapping outside already cancels. The index you receive is unaffected.
|
|
124
|
+
- **Sheets stack.** Opening one over another puts it on top, and each resolves its own promise. Opening a sheet over a `Modal` does not dismiss the modal.
|
|
125
|
+
- **Light or dark follows the system setting** unless `userInterfaceStyle` forces one, chosen when the sheet opens. The sheet uses its own palette, so it looks the same in any host app.
|
|
126
|
+
- **Long lists scroll**, with the title and message pinned above them.
|
|
127
|
+
- On Android, a sheet with no available activity resolves the cancel index rather than hanging, so a cancel handler can run for a sheet that never appeared.
|
|
128
|
+
|
|
129
|
+
## Testing
|
|
130
|
+
|
|
131
|
+
The package ships a Jest mock, so a screen that opens a sheet can be tested without the native module:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
jest.mock('react-native-unified-action-sheet', () =>
|
|
135
|
+
require('react-native-unified-action-sheet/jest')
|
|
136
|
+
);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every sheet then resolves with no selection. To simulate a tap, queue the index the next sheet should resolve with — the pressed button's `onPress` runs, just as it would for real:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { setNextButtonIndex } from 'react-native-unified-action-sheet/jest';
|
|
143
|
+
|
|
144
|
+
setNextButtonIndex(0);
|
|
145
|
+
await openTheSheet();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Prefer that over `mockResolvedValueOnce`, which replaces the implementation and so skips `onPress`. `dismissActionSheet` and `dismissAllActionSheets` are plain spies.
|
|
149
|
+
|
|
150
|
+
## Compatibility
|
|
151
|
+
|
|
152
|
+
| React Native | Old architecture | New architecture |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| 0.81.x | ✅ Android verified | ✅ Android verified |
|
|
155
|
+
| 0.85.x (latest line) | n/a (removed in RN 0.82+) | ✅ verified (iOS + Android) |
|
|
156
|
+
|
|
157
|
+
**iOS on RN 0.81 is unverified.** The pod integrates, but React Native 0.81 itself does not build under Xcode 26 — a toolchain clash unrelated to this library.
|
|
158
|
+
|
|
159
|
+
## Contributing
|
|
160
|
+
|
|
161
|
+
- [Development workflow](CONTRIBUTING.md#development-workflow)
|
|
162
|
+
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
|
|
163
|
+
- [Code of conduct](CODE_OF_CONDUCT.md)
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.UnifiedActionSheet = [
|
|
3
|
+
kotlinVersion: "2.0.21",
|
|
4
|
+
minSdkVersion: 24,
|
|
5
|
+
compileSdkVersion: 36
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
ext.getExtOrDefault = { prop ->
|
|
9
|
+
if (rootProject.ext.has(prop)) {
|
|
10
|
+
return rootProject.ext.get(prop)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return UnifiedActionSheet[prop]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
repositories {
|
|
17
|
+
google()
|
|
18
|
+
mavenCentral()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies {
|
|
22
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
23
|
+
// noinspection DifferentKotlinGradleVersion
|
|
24
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
apply plugin: "com.android.library"
|
|
30
|
+
apply plugin: "kotlin-android"
|
|
31
|
+
|
|
32
|
+
apply plugin: "com.facebook.react"
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "com.unifiedactionsheet"
|
|
36
|
+
|
|
37
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
38
|
+
|
|
39
|
+
defaultConfig {
|
|
40
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
compileOptions {
|
|
44
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
45
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
dependencies {
|
|
50
|
+
implementation "com.facebook.react:react-android"
|
|
51
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package com.unifiedactionsheet
|
|
2
|
+
|
|
3
|
+
import android.graphics.Rect
|
|
4
|
+
import com.facebook.react.bridge.ReadableMap
|
|
5
|
+
|
|
6
|
+
internal enum class PresentationStyle {
|
|
7
|
+
CENTERED,
|
|
8
|
+
ANCHORED,
|
|
9
|
+
;
|
|
10
|
+
|
|
11
|
+
companion object {
|
|
12
|
+
fun fromWire(value: String?): PresentationStyle =
|
|
13
|
+
if (value == "anchored") ANCHORED else CENTERED
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
internal enum class ButtonTextAlignment {
|
|
18
|
+
START,
|
|
19
|
+
CENTER,
|
|
20
|
+
;
|
|
21
|
+
|
|
22
|
+
companion object {
|
|
23
|
+
fun fromWire(value: String?): ButtonTextAlignment =
|
|
24
|
+
if (value == "center") CENTER else START
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
internal enum class AnchorAlignment {
|
|
29
|
+
START,
|
|
30
|
+
CENTER,
|
|
31
|
+
;
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
fun fromWire(value: String?): AnchorAlignment =
|
|
35
|
+
if (value == "center") CENTER else START
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
internal enum class ForcedAppearance {
|
|
40
|
+
SYSTEM,
|
|
41
|
+
LIGHT,
|
|
42
|
+
DARK,
|
|
43
|
+
;
|
|
44
|
+
|
|
45
|
+
companion object {
|
|
46
|
+
fun fromWire(value: String?): ForcedAppearance = when (value) {
|
|
47
|
+
"light" -> LIGHT
|
|
48
|
+
"dark" -> DARK
|
|
49
|
+
else -> SYSTEM
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
internal data class ActionSheetOptions(
|
|
55
|
+
val options: List<String>,
|
|
56
|
+
val cancelButtonIndex: Int?,
|
|
57
|
+
val destructiveButtonIndices: Set<Int>,
|
|
58
|
+
val title: String?,
|
|
59
|
+
val message: String?,
|
|
60
|
+
val tintColor: String?,
|
|
61
|
+
val cancelButtonTintColor: String?,
|
|
62
|
+
val destructiveColor: String?,
|
|
63
|
+
val buttonTextAlignment: ButtonTextAlignment,
|
|
64
|
+
val disabledButtonIndices: Set<Int>,
|
|
65
|
+
val userInterfaceStyle: ForcedAppearance,
|
|
66
|
+
val presentationStyle: PresentationStyle,
|
|
67
|
+
val anchorRect: Rect?,
|
|
68
|
+
val anchorAlignment: AnchorAlignment,
|
|
69
|
+
) {
|
|
70
|
+
companion object {
|
|
71
|
+
/// density converts the anchor rect: measureInWindow reports dp, while
|
|
72
|
+
/// View coordinates are px.
|
|
73
|
+
fun fromReadableMap(map: ReadableMap, density: Float): ActionSheetOptions {
|
|
74
|
+
val labels = mutableListOf<String>()
|
|
75
|
+
map.getArray("options")?.let { array ->
|
|
76
|
+
for (index in 0 until array.size()) {
|
|
77
|
+
array.getString(index)?.let(labels::add)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
val disabledIndices = mutableSetOf<Int>()
|
|
82
|
+
map.getArray("disabledButtonIndices")?.let { array ->
|
|
83
|
+
for (index in 0 until array.size()) {
|
|
84
|
+
disabledIndices.add(array.getInt(index))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
val destructiveIndices = mutableSetOf<Int>()
|
|
89
|
+
map.getArray("destructiveButtonIndices")?.let { array ->
|
|
90
|
+
for (index in 0 until array.size()) {
|
|
91
|
+
destructiveIndices.add(array.getInt(index))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return ActionSheetOptions(
|
|
96
|
+
options = labels,
|
|
97
|
+
cancelButtonIndex = optInt(map, "cancelButtonIndex"),
|
|
98
|
+
destructiveButtonIndices = destructiveIndices,
|
|
99
|
+
title = optString(map, "title"),
|
|
100
|
+
message = optString(map, "message"),
|
|
101
|
+
tintColor = optString(map, "tintColor"),
|
|
102
|
+
cancelButtonTintColor = optString(map, "cancelButtonTintColor"),
|
|
103
|
+
destructiveColor = optString(map, "destructiveColor"),
|
|
104
|
+
buttonTextAlignment = ButtonTextAlignment.fromWire(optString(map, "buttonTextAlignment")),
|
|
105
|
+
disabledButtonIndices = disabledIndices,
|
|
106
|
+
userInterfaceStyle = ForcedAppearance.fromWire(optString(map, "userInterfaceStyle")),
|
|
107
|
+
presentationStyle = PresentationStyle.fromWire(optString(map, "presentationStyle")),
|
|
108
|
+
anchorRect = optRect(map, "anchorRect", density),
|
|
109
|
+
anchorAlignment = AnchorAlignment.fromWire(optString(map, "anchorAlignment")),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun optRect(map: ReadableMap, key: String, density: Float): Rect? {
|
|
114
|
+
if (!map.hasKey(key) || map.isNull(key)) return null
|
|
115
|
+
val rect = map.getMap(key) ?: return null
|
|
116
|
+
|
|
117
|
+
val x = rect.getDouble("x") * density
|
|
118
|
+
val y = rect.getDouble("y") * density
|
|
119
|
+
|
|
120
|
+
return Rect(
|
|
121
|
+
x.toInt(),
|
|
122
|
+
y.toInt(),
|
|
123
|
+
(x + rect.getDouble("width") * density).toInt(),
|
|
124
|
+
(y + rect.getDouble("height") * density).toInt(),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun optInt(map: ReadableMap, key: String): Int? =
|
|
129
|
+
if (map.hasKey(key) && !map.isNull(key)) map.getInt(key) else null
|
|
130
|
+
|
|
131
|
+
private fun optString(map: ReadableMap, key: String): String? =
|
|
132
|
+
if (map.hasKey(key) && !map.isNull(key)) map.getString(key) else null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package com.unifiedactionsheet
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.util.TypedValue
|
|
6
|
+
import android.view.Gravity
|
|
7
|
+
import android.view.View
|
|
8
|
+
import android.widget.Button
|
|
9
|
+
import android.widget.LinearLayout
|
|
10
|
+
import android.widget.TextView
|
|
11
|
+
import androidx.appcompat.content.res.AppCompatResources
|
|
12
|
+
import androidx.core.graphics.ColorUtils
|
|
13
|
+
import androidx.core.view.AccessibilityDelegateCompat
|
|
14
|
+
import androidx.core.view.ViewCompat
|
|
15
|
+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
16
|
+
import androidx.core.widget.NestedScrollView
|
|
17
|
+
|
|
18
|
+
private const val DISABLED_TEXT_ALPHA = 97
|
|
19
|
+
|
|
20
|
+
internal fun buildContent(
|
|
21
|
+
context: Context,
|
|
22
|
+
options: ActionSheetOptions,
|
|
23
|
+
palette: SheetPalette,
|
|
24
|
+
includeCancelRow: Boolean = true,
|
|
25
|
+
onSelect: (Int) -> Unit,
|
|
26
|
+
): LinearLayout {
|
|
27
|
+
val container = LinearLayout(context).apply {
|
|
28
|
+
orientation = LinearLayout.VERTICAL
|
|
29
|
+
setPadding(dp(context, 8), dp(context, 8), dp(context, 8), dp(context, 16))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
options.title?.takeIf { it.isNotBlank() }?.let {
|
|
33
|
+
container.addView(buildHeader(context, it, palette.secondaryText, isTitle = true))
|
|
34
|
+
}
|
|
35
|
+
options.message?.takeIf { it.isNotBlank() }?.let {
|
|
36
|
+
container.addView(buildHeader(context, it, palette.secondaryText, isTitle = false))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val centerLabels = options.buttonTextAlignment == ButtonTextAlignment.CENTER
|
|
40
|
+
|
|
41
|
+
val optionColor = parseColor(options.tintColor) ?: palette.primaryText
|
|
42
|
+
val cancelColor = parseColor(options.cancelButtonTintColor) ?: optionColor
|
|
43
|
+
// Disabled rows drop their role color for a neutral dim, matching iOS, where
|
|
44
|
+
// UIKit owns the appearance of a disabled action.
|
|
45
|
+
val disabledColor = ColorUtils.setAlphaComponent(palette.primaryText, DISABLED_TEXT_ALPHA)
|
|
46
|
+
|
|
47
|
+
val optionRows = LinearLayout(context).apply {
|
|
48
|
+
orientation = LinearLayout.VERTICAL
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
options.options.forEachIndexed { index, label ->
|
|
52
|
+
if (index == options.cancelButtonIndex) return@forEachIndexed
|
|
53
|
+
val isDestructive = index in options.destructiveButtonIndices
|
|
54
|
+
optionRows.addView(
|
|
55
|
+
buildOption(
|
|
56
|
+
context = context,
|
|
57
|
+
label = label,
|
|
58
|
+
color = if (isDestructive) parseColor(options.destructiveColor) ?: palette.error else optionColor,
|
|
59
|
+
centered = centerLabels,
|
|
60
|
+
enabled = index !in options.disabledButtonIndices,
|
|
61
|
+
disabledColor = disabledColor,
|
|
62
|
+
onPress = { onSelect(index) },
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (includeCancelRow) {
|
|
68
|
+
options.cancelButtonIndex?.let { cancelIdx ->
|
|
69
|
+
if (cancelIdx in options.options.indices) {
|
|
70
|
+
optionRows.addView(buildSpacer(context))
|
|
71
|
+
optionRows.addView(
|
|
72
|
+
buildOption(
|
|
73
|
+
context = context,
|
|
74
|
+
label = options.options[cancelIdx],
|
|
75
|
+
color = cancelColor,
|
|
76
|
+
centered = centerLabels,
|
|
77
|
+
enabled = cancelIdx !in options.disabledButtonIndices,
|
|
78
|
+
disabledColor = disabledColor,
|
|
79
|
+
onPress = { onSelect(cancelIdx) },
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val scroll = NestedScrollView(context).apply {
|
|
87
|
+
addView(optionRows)
|
|
88
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
89
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
90
|
+
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
91
|
+
1f,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
container.addView(scroll)
|
|
95
|
+
|
|
96
|
+
return container
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun buildHeader(
|
|
100
|
+
context: Context,
|
|
101
|
+
text: String,
|
|
102
|
+
color: Int,
|
|
103
|
+
isTitle: Boolean,
|
|
104
|
+
): TextView =
|
|
105
|
+
TextView(context).apply {
|
|
106
|
+
this.text = text
|
|
107
|
+
gravity = Gravity.CENTER
|
|
108
|
+
setPadding(dp(context, 16), dp(context, 12), dp(context, 16), dp(context, 8))
|
|
109
|
+
setTextSize(TypedValue.COMPLEX_UNIT_SP, if (isTitle) 14f else 12f)
|
|
110
|
+
setTextColor(color)
|
|
111
|
+
if (isTitle) {
|
|
112
|
+
ViewCompat.setAccessibilityHeading(this, true)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun buildOption(
|
|
117
|
+
context: Context,
|
|
118
|
+
label: String,
|
|
119
|
+
color: Int,
|
|
120
|
+
centered: Boolean,
|
|
121
|
+
enabled: Boolean,
|
|
122
|
+
disabledColor: Int,
|
|
123
|
+
onPress: () -> Unit,
|
|
124
|
+
): View = TextView(context).apply {
|
|
125
|
+
text = label
|
|
126
|
+
gravity = if (centered) Gravity.CENTER_HORIZONTAL else Gravity.START
|
|
127
|
+
setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16))
|
|
128
|
+
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
|
129
|
+
setTextColor(if (enabled) color else disabledColor)
|
|
130
|
+
background = AppCompatResources.getDrawable(context, selectableItemBackgroundRes(context))
|
|
131
|
+
isClickable = true
|
|
132
|
+
isFocusable = true
|
|
133
|
+
isEnabled = enabled
|
|
134
|
+
setOnClickListener { onPress() }
|
|
135
|
+
contentDescription = label
|
|
136
|
+
ViewCompat.setAccessibilityDelegate(
|
|
137
|
+
this,
|
|
138
|
+
object : AccessibilityDelegateCompat() {
|
|
139
|
+
override fun onInitializeAccessibilityNodeInfo(
|
|
140
|
+
host: View,
|
|
141
|
+
info: AccessibilityNodeInfoCompat,
|
|
142
|
+
) {
|
|
143
|
+
super.onInitializeAccessibilityNodeInfo(host, info)
|
|
144
|
+
info.className = Button::class.java.name
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun buildSpacer(context: Context): View = View(context).apply {
|
|
151
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
152
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
153
|
+
dp(context, 8),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private fun parseColor(value: String?): Int? = value?.let {
|
|
158
|
+
runCatching { Color.parseColor(it) }.getOrNull()
|
|
159
|
+
}
|