react-native-unified-action-sheet 0.1.1 → 0.3.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +38 -105
  3. package/android/build.gradle +28 -1
  4. package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +6 -2
  5. package/android/src/main/java/com/unifiedactionsheet/PromptOptions.kt +132 -0
  6. package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +31 -0
  7. package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +54 -0
  8. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +61 -0
  9. package/android/src/material/java/com/unifiedactionsheet/BottomSheetPresenterFactory.kt +10 -0
  10. package/android/src/material/java/com/unifiedactionsheet/MaterialBottomSheetPresenter.kt +90 -0
  11. package/android/src/material/res/values/unified_action_sheet_bottom_sheet.xml +22 -0
  12. package/android/src/noMaterial/java/com/unifiedactionsheet/BottomSheetPresenterFactory.kt +10 -0
  13. package/app.plugin.js +1 -0
  14. package/ios/BottomSheetViewController.swift +208 -0
  15. package/ios/UnifiedActionSheet.mm +73 -1
  16. package/ios/UnifiedActionSheetImpl.swift +248 -11
  17. package/jest/index.d.ts +15 -1
  18. package/jest/index.js +28 -0
  19. package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -1
  20. package/lib/commonjs/action-sheet-options.interface.js +4 -0
  21. package/lib/commonjs/common-options.interface.js +2 -0
  22. package/lib/commonjs/common-options.interface.js.map +1 -0
  23. package/lib/commonjs/index.js +57 -5
  24. package/lib/commonjs/index.js.map +1 -1
  25. package/lib/commonjs/prompt-options.interface.js +6 -0
  26. package/lib/commonjs/prompt-options.interface.js.map +1 -0
  27. package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
  28. package/lib/module/action-sheet-options.interface.js +2 -0
  29. package/lib/module/common-options.interface.js +2 -0
  30. package/lib/module/common-options.interface.js.map +1 -0
  31. package/lib/module/index.js +55 -4
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/prompt-options.interface.js +4 -0
  34. package/lib/module/prompt-options.interface.js.map +1 -0
  35. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +23 -0
  36. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +5 -14
  38. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/src/common-options.interface.d.ts +17 -0
  40. package/lib/typescript/commonjs/src/common-options.interface.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/src/index.d.ts +4 -0
  42. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts +19 -0
  44. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts.map +1 -0
  45. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +23 -0
  46. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  47. package/lib/typescript/module/src/action-sheet-options.interface.d.ts +5 -14
  48. package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -1
  49. package/lib/typescript/module/src/common-options.interface.d.ts +17 -0
  50. package/lib/typescript/module/src/common-options.interface.d.ts.map +1 -0
  51. package/lib/typescript/module/src/index.d.ts +4 -0
  52. package/lib/typescript/module/src/index.d.ts.map +1 -1
  53. package/lib/typescript/module/src/prompt-options.interface.d.ts +19 -0
  54. package/lib/typescript/module/src/prompt-options.interface.d.ts.map +1 -0
  55. package/package.json +12 -2
  56. package/plugin/withUnifiedActionSheet.js +58 -0
  57. package/src/NativeUnifiedActionSheet.ts +22 -0
  58. package/src/action-sheet-options.interface.ts +10 -20
  59. package/src/common-options.interface.ts +18 -0
  60. package/src/index.tsx +100 -6
  61. package/src/prompt-options.interface.ts +27 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0]
11
+
12
+ ### Added
13
+
14
+ - `presentationStyle: 'bottom'`: a native bottom sheet on both platforms, with
15
+ a drag handle and swipe to dismiss. A long list opens part-way and drags up
16
+ to expand. On iOS it is a `UISheetPresentationController` sheet with native
17
+ rows (on iPad, the action sheet's popover instead); on Android, a Material 3
18
+ bottom sheet. `anchor` is ignored for it.
19
+ - Material is opt-in, so apps that don't use the bottom sheet stay free of the
20
+ dependency. Set `unifiedActionSheet.material=true` in
21
+ `android/gradle.properties`. Only then does the library add
22
+ `com.google.android.material` (1.12.0 by default; override it with
23
+ `ext.materialVersion` or `unifiedActionSheet.materialVersion`).
24
+ - An Expo config plugin that writes those properties:
25
+ `plugins: [['react-native-unified-action-sheet', { material: true }]]`, with
26
+ an optional `materialVersion`. `@expo/config-plugins` is an optional peer
27
+ dependency.
28
+
29
+ ### Fixed
30
+
31
+ - On iOS, a sheet, alert or prompt stayed on screen after a JS reload (pressing
32
+ `r` in Metro). It now closes on reload, as it already did on Android, and its
33
+ promise is left unresolved, since it belonged to the discarded runtime.
34
+
35
+ ### Notes
36
+
37
+ - Without the Material opt-in, `'bottom'` on Android falls back to the centered
38
+ dialog, and warns once in development.
39
+ - Prompts do not support `'bottom'`; they stay centered on both platforms.
40
+
41
+ ## [0.2.0]
42
+
43
+ ### Added
44
+
45
+ - `showPromptWithOptions(options)` — the same native dialog as the action sheet,
46
+ with a text field. React Native's own `Alert.prompt` is iOS-only and silently
47
+ does nothing on Android, so this is the one API here with no core equivalent.
48
+ Resolves `{ buttonIndex, text }`, or `undefined` after `dismissActionSheet()`.
49
+ - Prompt button handlers receive the field's text. It is read when the prompt
50
+ closes, including on a dismissal, so a draft is recoverable rather than lost.
51
+ - Prompt options: `placeholder`, `defaultValue`, `secureTextEntry` and
52
+ `keyboardType` (`'default' | 'email-address' | 'numeric' | 'phone-pad' |
53
+ 'url'`). Buttons keep the sheet's `style`, `disabled` and `onPress`.
54
+ - `setNextPromptResult({ buttonIndex, text })` in the shipped Jest mock, the
55
+ prompt counterpart to `setNextButtonIndex()`.
56
+ - Exported types: `PromptButtonInterface`, `PromptCommonOptionsInterface`,
57
+ `PromptAndroidOptionsInterface`, `PromptOptionsInterface`,
58
+ `PromptResultInterface`, plus the shared `BaseButtonInterface`,
59
+ `BaseOptionsInterface` and `BaseAndroidOptionsInterface`.
60
+
61
+ ### Changed
62
+
63
+ - The options and button fields the sheet and the prompt share are now declared
64
+ once, in `BaseOptionsInterface` / `BaseButtonInterface`, which both APIs
65
+ extend. No public type changed shape.
66
+
67
+ ### Notes
68
+
69
+ - A prompt is always presented centered. UIKit has no text field in an action
70
+ sheet, so `presentationStyle` and `anchor` do not apply to one.
71
+ - `dismissActionSheet()` and `dismissAllActionSheets()` close prompts too.
72
+
10
73
  ## [0.1.1]
11
74
 
12
75
  No user-facing changes — the library is identical to 0.1.0. Republished from CI
@@ -43,6 +106,8 @@ Initial release.
43
106
  - No `com.google.android.material` dependency, and no runtime dependencies —
44
107
  `react` and `react-native` are peers.
45
108
 
46
- [unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.1...HEAD
109
+ [unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.3.0...HEAD
110
+ [0.3.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.2.0...v0.3.0
111
+ [0.2.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.1...v0.2.0
47
112
  [0.1.1]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...v0.1.1
48
113
  [0.1.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,21 +1,16 @@
1
1
  # react-native-unified-action-sheet
2
2
 
3
- Unified action sheet API for React Native: a native `UIAlertController` on iOS, a native `AppCompatDialog` on Android.
3
+ Unified native action sheet for React Native, one API on iOS and Android.
4
4
 
5
- | Android | Android (dark mode) | iOS |
5
+ | | Light | Dark |
6
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" /> |
7
+ | **Android** | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/images/android-demo-light-mode.gif" width="280" alt="Android demo in light mode" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/images/android-demo-dark-mode.gif" width="280" alt="Android demo in dark mode" /> |
8
+ | **iOS** | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/images/ios-demo-light-mode.gif" width="280" alt="iOS demo in light mode" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/images/ios-demo-dark-mode.gif" width="280" alt="iOS demo in dark mode" /> |
8
9
 
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).
10
+ - **Fully native, always on top.** The sheet gets its own platform window, so it renders above your whole view tree, including an open [`Modal`](https://reactnative.dev/docs/modal). JS-rendered sheets live inside the component tree, where `overflow`, `zIndex` or a modal can clip or bury them.
11
+ - **One API on both platforms**: action sheets, anchored popovers, bottom sheets and text prompts.
12
+ - **Prompts on Android too.** React Native's `Alert.prompt` is iOS-only; `showPromptWithOptions()` works on both.
13
+ - **No dependencies.** `react` and `react-native` are peers; the Android bottom sheet's Material is opt-in.
19
14
 
20
15
  ## Installation
21
16
 
@@ -25,7 +20,9 @@ npm install react-native-unified-action-sheet
25
20
  yarn add react-native-unified-action-sheet
26
21
  ```
27
22
 
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**.
23
+ Autolinking handles the rest. In Expo, use a development build (`npx expo run:ios` / `npx expo run:android`); custom native modules do not work in Expo Go.
24
+
25
+ **Optional:** for the Android bottom sheet, set `unifiedActionSheet.material=true` in `android/gradle.properties`, or add the Expo plugin. See [Bottom sheets](docs/bottom-sheet.md).
29
26
 
30
27
  ## Usage
31
28
 
@@ -44,108 +41,44 @@ await showActionSheetWithOptions({
44
41
  });
45
42
  ```
46
43
 
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
44
+ The call resolves with the tapped button's index, and that button's `onPress` runs. A backdrop tap or back resolves the `'cancel'` button. The promise never rejects. See [Behavior](docs/behavior.md).
58
45
 
59
- `presentationStyle: 'anchored'` attaches the sheet to a view. Pass the ref itself; the library measures it and sends only the resulting rectangle across:
46
+ ### Presentation styles
60
47
 
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
- ```
48
+ | `presentationStyle` | iOS | Android |
49
+ | --- | --- | --- |
50
+ | unset | Action sheet from the bottom | Centered dialog |
51
+ | `'centered'` | Centered alert | Centered dialog |
52
+ | `'anchored'` | Popover on iPad, pointing at `anchor` | Popup attached to `anchor` |
53
+ | `'bottom'` | Sheet with a grabber (popover on iPad) | Material bottom sheet ([opt-in](docs/bottom-sheet.md)) |
88
54
 
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:
55
+ ### Prompts
109
56
 
110
57
  ```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.
58
+ import { showPromptWithOptions } from 'react-native-unified-action-sheet';
128
59
 
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
- );
60
+ const result = await showPromptWithOptions({
61
+ title: 'Rename item',
62
+ placeholder: 'New name',
63
+ defaultValue: 'Untitled',
64
+ options: [
65
+ { label: 'Save', onPress: (text) => rename(text) },
66
+ { label: 'Cancel', style: 'cancel' },
67
+ ],
68
+ });
69
+ // { buttonIndex, text }, or undefined after dismissActionSheet().
137
70
  ```
138
71
 
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:
72
+ ### Dismissing from code
140
73
 
141
- ```ts
142
- import { setNextButtonIndex } from 'react-native-unified-action-sheet/jest';
74
+ `dismissActionSheet()` closes the most recently opened sheet, and `dismissAllActionSheets()` closes every open one. Dismissed sheets resolve with `undefined`.
143
75
 
144
- setNextButtonIndex(0);
145
- await openTheSheet();
146
- ```
76
+ ## Documentation
147
77
 
148
- Prefer that over `mockResolvedValueOnce`, which replaces the implementation and so skips `onPress`. `dismissActionSheet` and `dismissAllActionSheets` are plain spies.
78
+ - [API reference](docs/api.md): every option, per platform
79
+ - [Behavior](docs/behavior.md): what each gesture resolves with, and platform differences
80
+ - [Bottom sheets](docs/bottom-sheet.md): both platforms, and enabling Material on Android
81
+ - [Testing](docs/testing.md): the shipped Jest mock
149
82
 
150
83
  ## Compatibility
151
84
 
@@ -2,7 +2,8 @@ buildscript {
2
2
  ext.UnifiedActionSheet = [
3
3
  kotlinVersion: "2.0.21",
4
4
  minSdkVersion: 24,
5
- compileSdkVersion: 36
5
+ compileSdkVersion: 36,
6
+ materialVersion: "1.12.0"
6
7
  ]
7
8
 
8
9
  ext.getExtOrDefault = { prop ->
@@ -31,6 +32,20 @@ apply plugin: "kotlin-android"
31
32
 
32
33
  apply plugin: "com.facebook.react"
33
34
 
35
+ // Material is opt-in, so apps that don't use presentationStyle 'bottom' stay
36
+ // free of the dependency. The app sets unifiedActionSheet.material=true in its
37
+ // gradle.properties (the Expo config plugin writes it); only then is the
38
+ // Material presenter compiled and the dependency added. src/main never
39
+ // references a Material class: it goes through BottomSheetPresenterFactory,
40
+ // which both variant source sets define.
41
+ def materialEnabled = (findProperty("unifiedActionSheet.material") ?: "false").toString().toBoolean()
42
+
43
+ // ext.materialVersion on the root project wins, then the gradle property, then
44
+ // the version this library is tested against.
45
+ def materialVersion = rootProject.ext.has("materialVersion")
46
+ ? rootProject.ext.get("materialVersion")
47
+ : (findProperty("unifiedActionSheet.materialVersion") ?: UnifiedActionSheet.materialVersion)
48
+
34
49
  android {
35
50
  namespace "com.unifiedactionsheet"
36
51
 
@@ -40,6 +55,15 @@ android {
40
55
  minSdkVersion getExtOrDefault("minSdkVersion")
41
56
  }
42
57
 
58
+ sourceSets {
59
+ main {
60
+ java.srcDirs += materialEnabled ? "src/material/java" : "src/noMaterial/java"
61
+ if (materialEnabled) {
62
+ res.srcDirs += "src/material/res"
63
+ }
64
+ }
65
+ }
66
+
43
67
  compileOptions {
44
68
  sourceCompatibility JavaVersion.VERSION_17
45
69
  targetCompatibility JavaVersion.VERSION_17
@@ -48,4 +72,7 @@ android {
48
72
 
49
73
  dependencies {
50
74
  implementation "com.facebook.react:react-android"
75
+ if (materialEnabled) {
76
+ implementation "com.google.android.material:material:${materialVersion}"
77
+ }
51
78
  }
@@ -6,11 +6,15 @@ import com.facebook.react.bridge.ReadableMap
6
6
  internal enum class PresentationStyle {
7
7
  CENTERED,
8
8
  ANCHORED,
9
+ BOTTOM,
9
10
  ;
10
11
 
11
12
  companion object {
12
- fun fromWire(value: String?): PresentationStyle =
13
- if (value == "anchored") ANCHORED else CENTERED
13
+ fun fromWire(value: String?): PresentationStyle = when (value) {
14
+ "anchored" -> ANCHORED
15
+ "bottom" -> BOTTOM
16
+ else -> CENTERED
17
+ }
14
18
  }
15
19
  }
16
20
 
@@ -0,0 +1,132 @@
1
+ package com.unifiedactionsheet
2
+
3
+ import android.text.InputType
4
+ import com.facebook.react.bridge.ReadableMap
5
+
6
+ /// The subset of React Native's keyboardType values that map onto both
7
+ /// platforms. Anything unrecognised falls back to the plain text keyboard.
8
+ internal enum class PromptKeyboardType {
9
+ DEFAULT,
10
+ EMAIL_ADDRESS,
11
+ NUMERIC,
12
+ PHONE_PAD,
13
+ URL,
14
+ ;
15
+
16
+ fun toInputType(secure: Boolean): Int {
17
+ if (secure) {
18
+ // Keep the keyboard class: masking a passcode should still show digits.
19
+ return when (this) {
20
+ NUMERIC, PHONE_PAD ->
21
+ InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
22
+ else -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
23
+ }
24
+ }
25
+
26
+ return when (this) {
27
+ DEFAULT -> InputType.TYPE_CLASS_TEXT
28
+ EMAIL_ADDRESS -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
29
+ NUMERIC -> InputType.TYPE_CLASS_NUMBER
30
+ PHONE_PAD -> InputType.TYPE_CLASS_PHONE
31
+ URL -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
32
+ }
33
+ }
34
+
35
+ companion object {
36
+ fun fromWire(value: String?): PromptKeyboardType = when (value) {
37
+ "email-address" -> EMAIL_ADDRESS
38
+ "numeric" -> NUMERIC
39
+ "phone-pad" -> PHONE_PAD
40
+ "url" -> URL
41
+ else -> DEFAULT
42
+ }
43
+ }
44
+ }
45
+
46
+ internal data class PromptOptions(
47
+ val options: List<String>,
48
+ val cancelButtonIndex: Int?,
49
+ val destructiveButtonIndices: Set<Int>,
50
+ val disabledButtonIndices: Set<Int>,
51
+ val title: String?,
52
+ val message: String?,
53
+ val placeholder: String?,
54
+ val defaultValue: String?,
55
+ val keyboardType: PromptKeyboardType,
56
+ val secureTextEntry: Boolean,
57
+ val tintColor: String?,
58
+ val cancelButtonTintColor: String?,
59
+ val destructiveColor: String?,
60
+ val buttonTextAlignment: ButtonTextAlignment,
61
+ val userInterfaceStyle: ForcedAppearance,
62
+ ) {
63
+ /// The prompt reuses the sheet's content and dialog code, which are written
64
+ /// against ActionSheetOptions. Presentation is fixed to centered: UIKit has
65
+ /// no text field in an action sheet, so an anchored prompt has no iOS
66
+ /// counterpart and would break the "same options behave the same way" rule.
67
+ fun toSheetOptions(): ActionSheetOptions = ActionSheetOptions(
68
+ options = options,
69
+ cancelButtonIndex = cancelButtonIndex,
70
+ destructiveButtonIndices = destructiveButtonIndices,
71
+ title = title,
72
+ message = message,
73
+ tintColor = tintColor,
74
+ cancelButtonTintColor = cancelButtonTintColor,
75
+ destructiveColor = destructiveColor,
76
+ buttonTextAlignment = buttonTextAlignment,
77
+ disabledButtonIndices = disabledButtonIndices,
78
+ userInterfaceStyle = userInterfaceStyle,
79
+ presentationStyle = PresentationStyle.CENTERED,
80
+ anchorRect = null,
81
+ anchorAlignment = AnchorAlignment.START,
82
+ )
83
+
84
+ companion object {
85
+ fun fromReadableMap(map: ReadableMap): PromptOptions {
86
+ val labels = mutableListOf<String>()
87
+ map.getArray("options")?.let { array ->
88
+ for (index in 0 until array.size()) {
89
+ array.getString(index)?.let(labels::add)
90
+ }
91
+ }
92
+
93
+ return PromptOptions(
94
+ options = labels,
95
+ cancelButtonIndex = optInt(map, "cancelButtonIndex"),
96
+ destructiveButtonIndices = optIndices(map, "destructiveButtonIndices"),
97
+ disabledButtonIndices = optIndices(map, "disabledButtonIndices"),
98
+ title = optString(map, "title"),
99
+ message = optString(map, "message"),
100
+ placeholder = optString(map, "placeholder"),
101
+ defaultValue = optString(map, "defaultValue"),
102
+ keyboardType = PromptKeyboardType.fromWire(optString(map, "keyboardType")),
103
+ secureTextEntry = optBoolean(map, "secureTextEntry"),
104
+ tintColor = optString(map, "tintColor"),
105
+ cancelButtonTintColor = optString(map, "cancelButtonTintColor"),
106
+ destructiveColor = optString(map, "destructiveColor"),
107
+ buttonTextAlignment = ButtonTextAlignment.fromWire(optString(map, "buttonTextAlignment")),
108
+ userInterfaceStyle = ForcedAppearance.fromWire(optString(map, "userInterfaceStyle")),
109
+ )
110
+ }
111
+
112
+ private fun optIndices(map: ReadableMap, key: String): Set<Int> {
113
+ val indices = mutableSetOf<Int>()
114
+ map.getArray(key)?.let { array ->
115
+ for (index in 0 until array.size()) {
116
+ indices.add(array.getInt(index))
117
+ }
118
+ }
119
+
120
+ return indices
121
+ }
122
+
123
+ private fun optInt(map: ReadableMap, key: String): Int? =
124
+ if (map.hasKey(key) && !map.isNull(key)) map.getInt(key) else null
125
+
126
+ private fun optBoolean(map: ReadableMap, key: String): Boolean =
127
+ map.hasKey(key) && !map.isNull(key) && map.getBoolean(key)
128
+
129
+ private fun optString(map: ReadableMap, key: String): String? =
130
+ if (map.hasKey(key) && !map.isNull(key)) map.getString(key) else null
131
+ }
132
+ }
@@ -6,9 +6,11 @@ import android.util.TypedValue
6
6
  import android.view.Gravity
7
7
  import android.view.View
8
8
  import android.widget.Button
9
+ import android.widget.EditText
9
10
  import android.widget.LinearLayout
10
11
  import android.widget.TextView
11
12
  import androidx.appcompat.content.res.AppCompatResources
13
+ import androidx.appcompat.widget.AppCompatEditText
12
14
  import androidx.core.graphics.ColorUtils
13
15
  import androidx.core.view.AccessibilityDelegateCompat
14
16
  import androidx.core.view.ViewCompat
@@ -22,6 +24,8 @@ internal fun buildContent(
22
24
  options: ActionSheetOptions,
23
25
  palette: SheetPalette,
24
26
  includeCancelRow: Boolean = true,
27
+ /// Sits between the message and the rows. Only the prompt supplies one.
28
+ inputView: View? = null,
25
29
  onSelect: (Int) -> Unit,
26
30
  ): LinearLayout {
27
31
  val container = LinearLayout(context).apply {
@@ -36,6 +40,8 @@ internal fun buildContent(
36
40
  container.addView(buildHeader(context, it, palette.secondaryText, isTitle = false))
37
41
  }
38
42
 
43
+ inputView?.let(container::addView)
44
+
39
45
  val centerLabels = options.buttonTextAlignment == ButtonTextAlignment.CENTER
40
46
 
41
47
  val optionColor = parseColor(options.tintColor) ?: palette.primaryText
@@ -96,6 +102,31 @@ internal fun buildContent(
96
102
  return container
97
103
  }
98
104
 
105
+ internal fun buildPromptInput(
106
+ context: Context,
107
+ options: PromptOptions,
108
+ palette: SheetPalette,
109
+ ): EditText = AppCompatEditText(context).apply {
110
+ setText(options.defaultValue.orEmpty())
111
+ hint = options.placeholder
112
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
113
+ setTextColor(palette.primaryText)
114
+ setHintTextColor(ColorUtils.setAlphaComponent(palette.secondaryText, DISABLED_TEXT_ALPHA))
115
+ setPadding(dp(context, 16), dp(context, 8), dp(context, 16), dp(context, 8))
116
+ isSingleLine = true
117
+ // After isSingleLine, never before: setSingleLine() installs
118
+ // SingleLineTransformationMethod, which replaces the password masking that
119
+ // setInputType() sets up. Ordering them the other way renders a secure field
120
+ // in plain text while still reporting a password input type.
121
+ inputType = options.keyboardType.toInputType(options.secureTextEntry)
122
+ // Land the caret after any default value rather than before it.
123
+ setSelection(text?.length ?: 0)
124
+ layoutParams = LinearLayout.LayoutParams(
125
+ LinearLayout.LayoutParams.MATCH_PARENT,
126
+ LinearLayout.LayoutParams.WRAP_CONTENT,
127
+ )
128
+ }
129
+
99
130
  private fun buildHeader(
100
131
  context: Context,
101
132
  text: String,
@@ -83,6 +83,60 @@ internal object CenteredDialogPresenter : SheetPresenter {
83
83
  }
84
84
  }
85
85
 
86
+ /// The centered dialog with a text field above the buttons. Deliberately not a
87
+ /// SheetPresenter: it takes PromptOptions and hands the field's text back with
88
+ /// the index, which the sheet interface has no place for.
89
+ internal fun buildPromptDialog(
90
+ activity: Activity,
91
+ options: PromptOptions,
92
+ onSelect: (Dialog, Int, String) -> Unit,
93
+ ): Pair<Dialog, () -> String> {
94
+ val isDark = isDarkAppearance(activity, options.userInterfaceStyle)
95
+ val palette = paletteFor(isDark)
96
+ val dialog = AppCompatDialog(activity, dialogTheme(isDark))
97
+ dialog.supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
98
+
99
+ val context: Context = dialog.context
100
+ val input = buildPromptInput(context, options, palette)
101
+ val currentText = { input.text?.toString().orEmpty() }
102
+
103
+ val container = buildContent(
104
+ context = context,
105
+ options = options.toSheetOptions(),
106
+ palette = palette,
107
+ inputView = input,
108
+ ) { index -> onSelect(dialog, index, currentText()) }
109
+
110
+ dialog.setContentView(container)
111
+ dialog.setCanceledOnTouchOutside(true)
112
+
113
+ val background = GradientDrawable().apply {
114
+ cornerRadius = dp(context, PROMPT_CORNER_RADIUS_DP).toFloat()
115
+ setColor(palette.surface)
116
+ }
117
+ dialog.window?.setBackgroundDrawable(background)
118
+ container.background = background.constantState?.newDrawable() ?: background
119
+ container.clipToOutline = true
120
+
121
+ val metrics = context.resources.displayMetrics
122
+ val width = (metrics.widthPixels - 2 * dp(context, PROMPT_MARGIN_DP))
123
+ .coerceAtMost(dp(context, PROMPT_MAX_WIDTH_DP))
124
+ .coerceAtLeast(minOf(dp(context, PROMPT_MIN_WIDTH_DP), metrics.widthPixels))
125
+ dialog.window?.setLayout(width, WindowManager.LayoutParams.WRAP_CONTENT)
126
+
127
+ // A prompt exists to be typed into, so raise the keyboard with it rather than
128
+ // making the user tap the field first.
129
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
130
+ input.requestFocus()
131
+
132
+ return dialog to currentText
133
+ }
134
+
135
+ private const val PROMPT_CORNER_RADIUS_DP = 28
136
+ private const val PROMPT_MARGIN_DP = 24
137
+ private const val PROMPT_MIN_WIDTH_DP = 280
138
+ private const val PROMPT_MAX_WIDTH_DP = 560
139
+
86
140
  internal class AnchoredDialogPresenter(private val anchorRect: Rect) : SheetPresenter {
87
141
  override fun build(
88
142
  activity: Activity,