react-native-unified-action-sheet 0.3.0 → 0.4.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 +55 -1
- package/README.md +24 -4
- package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +35 -0
- package/android/src/main/java/com/unifiedactionsheet/PromptOptions.kt +27 -5
- package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +75 -9
- package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +61 -10
- package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +8 -7
- package/android/src/material/java/com/unifiedactionsheet/MaterialBottomSheetPresenter.kt +65 -2
- package/ios/BottomSheetViewController.swift +3 -1
- package/ios/UnifiedActionSheet.mm +25 -4
- package/ios/UnifiedActionSheetImpl.swift +124 -27
- package/jest/index.d.ts +12 -9
- package/jest/index.js +37 -18
- package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -1
- package/lib/commonjs/index.js +84 -17
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
- package/lib/module/index.js +84 -17
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +7 -1
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +4 -1
- package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/common-options.interface.d.ts +9 -0
- package/lib/typescript/commonjs/src/common-options.interface.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +6 -6
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/prompt-options.interface.d.ts +10 -6
- package/lib/typescript/commonjs/src/prompt-options.interface.d.ts.map +1 -1
- package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +7 -1
- package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
- package/lib/typescript/module/src/action-sheet-options.interface.d.ts +4 -1
- package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -1
- package/lib/typescript/module/src/common-options.interface.d.ts +9 -0
- package/lib/typescript/module/src/common-options.interface.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +6 -6
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/prompt-options.interface.d.ts +10 -6
- package/lib/typescript/module/src/prompt-options.interface.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeUnifiedActionSheet.ts +7 -2
- package/src/action-sheet-options.interface.ts +11 -0
- package/src/common-options.interface.ts +18 -0
- package/src/index.tsx +112 -39
- package/src/prompt-options.interface.ts +21 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0]
|
|
11
|
+
|
|
12
|
+
### Changed (breaking)
|
|
13
|
+
|
|
14
|
+
- **Results carry a reason.** `showActionSheetWithOptions()` resolves
|
|
15
|
+
`{ reason, buttonIndex }` instead of a bare index, and
|
|
16
|
+
`showPromptWithOptions()` resolves `{ reason, buttonIndex, text, password? }`
|
|
17
|
+
instead of `{ buttonIndex, text }` or `undefined`. `reason` is `'selected'`,
|
|
18
|
+
`'cancelled'` (the cancel button, a backdrop tap, back or a swipe down) or
|
|
19
|
+
`'dismissed'` (closed from code or by a JS reload). A dismissal used to
|
|
20
|
+
resolve `undefined`; it now resolves `{ reason: 'dismissed', buttonIndex:
|
|
21
|
+
undefined }`, and a dismissed prompt keeps the text that was in its field.
|
|
22
|
+
The result types are discriminated unions on `reason`.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// before
|
|
26
|
+
const index = await showActionSheetWithOptions(options);
|
|
27
|
+
if (index === undefined) { /* dismissed */ }
|
|
28
|
+
|
|
29
|
+
// after
|
|
30
|
+
const { reason, buttonIndex } = await showActionSheetWithOptions(options);
|
|
31
|
+
if (reason === 'dismissed') { /* dismissed */ }
|
|
32
|
+
```
|
|
33
|
+
- **Prompt `onPress` receives an object.** It is called with
|
|
34
|
+
`{ text, password? }` instead of the text string:
|
|
35
|
+
`onPress: ({ text }) => rename(text)`.
|
|
36
|
+
- The Jest mock resolves the same shapes, deriving `reason` as the real module
|
|
37
|
+
does; `setNextPromptResult` accepts an optional `password`.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `preferred` on a button: the default action, emphasized. On iOS it is the
|
|
42
|
+
alert's `preferredAction`, drawn in UIKit's own style (a filled button from
|
|
43
|
+
iOS 26, bold text before), so the return key presses it in a prompt; in the
|
|
44
|
+
bottom sheet it is a semibold row; UIKit ignores it in the standard action
|
|
45
|
+
sheet. On Android the row is bold, and in a prompt the keyboard's done key
|
|
46
|
+
presses it.
|
|
47
|
+
- `detents` for `presentationStyle: 'bottom'`: `'auto'`, `'medium'` and
|
|
48
|
+
`'large'`. The sheet opens at the first and can be dragged to the others.
|
|
49
|
+
- Prompt `type`: `'plain-text'`, `'secure-text'` or `'login-password'`, as in
|
|
50
|
+
React Native's `Alert.prompt`. `'login-password'` adds a masked password
|
|
51
|
+
field (`passwordPlaceholder`), resolves `password`, and marks both fields
|
|
52
|
+
for iOS AutoFill and Android autofill. `secureTextEntry` still works, as
|
|
53
|
+
`type: 'secure-text'`.
|
|
54
|
+
- `requiresText` on a prompt button keeps it disabled while a field is empty.
|
|
55
|
+
- New exported types: `ActionSheetResultInterface`, `ActionSheetDetent`,
|
|
56
|
+
`CloseReason`, `CloseResultInterface` and `PromptValuesInterface`.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- iOS: alerts and prompts were never freed after closing. Each action's handler
|
|
61
|
+
held its presentation, which held the alert, which held the action.
|
|
62
|
+
|
|
10
63
|
## [0.3.0]
|
|
11
64
|
|
|
12
65
|
### Added
|
|
@@ -106,7 +159,8 @@ Initial release.
|
|
|
106
159
|
- No `com.google.android.material` dependency, and no runtime dependencies —
|
|
107
160
|
`react` and `react-native` are peers.
|
|
108
161
|
|
|
109
|
-
[unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.
|
|
162
|
+
[unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.4.0...HEAD
|
|
163
|
+
[0.4.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.3.0...v0.4.0
|
|
110
164
|
[0.3.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.2.0...v0.3.0
|
|
111
165
|
[0.2.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.1...v0.2.0
|
|
112
166
|
[0.1.1]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...v0.1.1
|
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ await showActionSheetWithOptions({
|
|
|
41
41
|
});
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
The call resolves
|
|
44
|
+
The call resolves `{ reason, buttonIndex }`, and the tapped button's `onPress` runs. `reason` is `'selected'`, `'cancelled'` (the cancel button, a backdrop tap, back or a swipe) or `'dismissed'` (closed from code). The promise never rejects. See [Behavior](docs/behavior.md).
|
|
45
45
|
|
|
46
46
|
### Presentation styles
|
|
47
47
|
|
|
@@ -62,16 +62,36 @@ const result = await showPromptWithOptions({
|
|
|
62
62
|
placeholder: 'New name',
|
|
63
63
|
defaultValue: 'Untitled',
|
|
64
64
|
options: [
|
|
65
|
-
{ label: 'Save', onPress: (text) => rename(text) },
|
|
65
|
+
{ label: 'Save', preferred: true, onPress: ({ text }) => rename(text) },
|
|
66
|
+
{ label: 'Cancel', style: 'cancel' },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
// { reason, buttonIndex, text }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`type: 'login-password'` adds a password field, and `requiresText: true` keeps a button disabled until the fields are filled:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
await showPromptWithOptions({
|
|
76
|
+
title: 'Sign in',
|
|
77
|
+
type: 'login-password',
|
|
78
|
+
placeholder: 'Email',
|
|
79
|
+
passwordPlaceholder: 'Password',
|
|
80
|
+
options: [
|
|
81
|
+
{
|
|
82
|
+
label: 'Sign in',
|
|
83
|
+
preferred: true,
|
|
84
|
+
requiresText: true,
|
|
85
|
+
onPress: ({ text, password }) => signIn(text, password),
|
|
86
|
+
},
|
|
66
87
|
{ label: 'Cancel', style: 'cancel' },
|
|
67
88
|
],
|
|
68
89
|
});
|
|
69
|
-
// { buttonIndex, text }, or undefined after dismissActionSheet().
|
|
70
90
|
```
|
|
71
91
|
|
|
72
92
|
### Dismissing from code
|
|
73
93
|
|
|
74
|
-
`dismissActionSheet()` closes the most recently opened sheet, and `dismissAllActionSheets()` closes every open one.
|
|
94
|
+
`dismissActionSheet()` closes the most recently opened sheet, and `dismissAllActionSheets()` closes every open one. They resolve with `reason: 'dismissed'`.
|
|
75
95
|
|
|
76
96
|
## Documentation
|
|
77
97
|
|
|
@@ -40,6 +40,24 @@ internal enum class AnchorAlignment {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/// A bottom sheet's resting height: the content, half the screen, or the full
|
|
44
|
+
/// height below the status bar.
|
|
45
|
+
internal enum class Detent {
|
|
46
|
+
AUTO,
|
|
47
|
+
MEDIUM,
|
|
48
|
+
LARGE,
|
|
49
|
+
;
|
|
50
|
+
|
|
51
|
+
companion object {
|
|
52
|
+
fun fromWire(value: String?): Detent? = when (value) {
|
|
53
|
+
"auto" -> AUTO
|
|
54
|
+
"medium" -> MEDIUM
|
|
55
|
+
"large" -> LARGE
|
|
56
|
+
else -> null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
internal enum class ForcedAppearance {
|
|
44
62
|
SYSTEM,
|
|
45
63
|
LIGHT,
|
|
@@ -70,6 +88,10 @@ internal data class ActionSheetOptions(
|
|
|
70
88
|
val presentationStyle: PresentationStyle,
|
|
71
89
|
val anchorRect: Rect?,
|
|
72
90
|
val anchorAlignment: AnchorAlignment,
|
|
91
|
+
/// Bold, as the default action. Only the first preferred button counts.
|
|
92
|
+
val preferredButtonIndex: Int? = null,
|
|
93
|
+
/// 'bottom' only, in the caller's order: the sheet opens at the first.
|
|
94
|
+
val detents: List<Detent> = emptyList(),
|
|
73
95
|
) {
|
|
74
96
|
companion object {
|
|
75
97
|
/// density converts the anchor rect: measureInWindow reports dp, while
|
|
@@ -111,9 +133,22 @@ internal data class ActionSheetOptions(
|
|
|
111
133
|
presentationStyle = PresentationStyle.fromWire(optString(map, "presentationStyle")),
|
|
112
134
|
anchorRect = optRect(map, "anchorRect", density),
|
|
113
135
|
anchorAlignment = AnchorAlignment.fromWire(optString(map, "anchorAlignment")),
|
|
136
|
+
preferredButtonIndex = optInt(map, "preferredButtonIndex"),
|
|
137
|
+
detents = optDetents(map),
|
|
114
138
|
)
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
private fun optDetents(map: ReadableMap): List<Detent> {
|
|
142
|
+
val detents = mutableListOf<Detent>()
|
|
143
|
+
map.getArray("detents")?.let { array ->
|
|
144
|
+
for (index in 0 until array.size()) {
|
|
145
|
+
Detent.fromWire(array.getString(index))?.let(detents::add)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return detents.distinct()
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
private fun optRect(map: ReadableMap, key: String, density: Float): Rect? {
|
|
118
153
|
if (!map.hasKey(key) || map.isNull(key)) return null
|
|
119
154
|
val rect = map.getMap(key) ?: return null
|
|
@@ -43,6 +43,23 @@ internal enum class PromptKeyboardType {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/// As in React Native's Alert.prompt. LOGIN_PASSWORD adds a secure field below
|
|
47
|
+
/// the first.
|
|
48
|
+
internal enum class PromptType {
|
|
49
|
+
PLAIN_TEXT,
|
|
50
|
+
SECURE_TEXT,
|
|
51
|
+
LOGIN_PASSWORD,
|
|
52
|
+
;
|
|
53
|
+
|
|
54
|
+
companion object {
|
|
55
|
+
fun fromWire(value: String?): PromptType = when (value) {
|
|
56
|
+
"secure-text" -> SECURE_TEXT
|
|
57
|
+
"login-password" -> LOGIN_PASSWORD
|
|
58
|
+
else -> PLAIN_TEXT
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
internal data class PromptOptions(
|
|
47
64
|
val options: List<String>,
|
|
48
65
|
val cancelButtonIndex: Int?,
|
|
@@ -50,10 +67,14 @@ internal data class PromptOptions(
|
|
|
50
67
|
val disabledButtonIndices: Set<Int>,
|
|
51
68
|
val title: String?,
|
|
52
69
|
val message: String?,
|
|
70
|
+
val type: PromptType,
|
|
53
71
|
val placeholder: String?,
|
|
72
|
+
val passwordPlaceholder: String?,
|
|
54
73
|
val defaultValue: String?,
|
|
55
74
|
val keyboardType: PromptKeyboardType,
|
|
56
|
-
val
|
|
75
|
+
val preferredButtonIndex: Int?,
|
|
76
|
+
/// Buttons kept disabled while any field is empty.
|
|
77
|
+
val textRequiredButtonIndices: Set<Int>,
|
|
57
78
|
val tintColor: String?,
|
|
58
79
|
val cancelButtonTintColor: String?,
|
|
59
80
|
val destructiveColor: String?,
|
|
@@ -79,6 +100,7 @@ internal data class PromptOptions(
|
|
|
79
100
|
presentationStyle = PresentationStyle.CENTERED,
|
|
80
101
|
anchorRect = null,
|
|
81
102
|
anchorAlignment = AnchorAlignment.START,
|
|
103
|
+
preferredButtonIndex = preferredButtonIndex,
|
|
82
104
|
)
|
|
83
105
|
|
|
84
106
|
companion object {
|
|
@@ -97,10 +119,13 @@ internal data class PromptOptions(
|
|
|
97
119
|
disabledButtonIndices = optIndices(map, "disabledButtonIndices"),
|
|
98
120
|
title = optString(map, "title"),
|
|
99
121
|
message = optString(map, "message"),
|
|
122
|
+
type = PromptType.fromWire(optString(map, "type")),
|
|
100
123
|
placeholder = optString(map, "placeholder"),
|
|
124
|
+
passwordPlaceholder = optString(map, "passwordPlaceholder"),
|
|
101
125
|
defaultValue = optString(map, "defaultValue"),
|
|
102
126
|
keyboardType = PromptKeyboardType.fromWire(optString(map, "keyboardType")),
|
|
103
|
-
|
|
127
|
+
preferredButtonIndex = optInt(map, "preferredButtonIndex"),
|
|
128
|
+
textRequiredButtonIndices = optIndices(map, "textRequiredButtonIndices"),
|
|
104
129
|
tintColor = optString(map, "tintColor"),
|
|
105
130
|
cancelButtonTintColor = optString(map, "cancelButtonTintColor"),
|
|
106
131
|
destructiveColor = optString(map, "destructiveColor"),
|
|
@@ -123,9 +148,6 @@ internal data class PromptOptions(
|
|
|
123
148
|
private fun optInt(map: ReadableMap, key: String): Int? =
|
|
124
149
|
if (map.hasKey(key) && !map.isNull(key)) map.getInt(key) else null
|
|
125
150
|
|
|
126
|
-
private fun optBoolean(map: ReadableMap, key: String): Boolean =
|
|
127
|
-
map.hasKey(key) && !map.isNull(key) && map.getBoolean(key)
|
|
128
|
-
|
|
129
151
|
private fun optString(map: ReadableMap, key: String): String? =
|
|
130
152
|
if (map.hasKey(key) && !map.isNull(key)) map.getString(key) else null
|
|
131
153
|
}
|
|
@@ -2,15 +2,19 @@ package com.unifiedactionsheet
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.graphics.Color
|
|
5
|
+
import android.graphics.Typeface
|
|
6
|
+
import android.os.Build
|
|
5
7
|
import android.util.TypedValue
|
|
6
8
|
import android.view.Gravity
|
|
7
9
|
import android.view.View
|
|
10
|
+
import android.view.ViewGroup
|
|
8
11
|
import android.widget.Button
|
|
9
12
|
import android.widget.EditText
|
|
10
13
|
import android.widget.LinearLayout
|
|
11
14
|
import android.widget.TextView
|
|
12
15
|
import androidx.appcompat.content.res.AppCompatResources
|
|
13
16
|
import androidx.appcompat.widget.AppCompatEditText
|
|
17
|
+
import androidx.appcompat.widget.AppCompatTextView
|
|
14
18
|
import androidx.core.graphics.ColorUtils
|
|
15
19
|
import androidx.core.view.AccessibilityDelegateCompat
|
|
16
20
|
import androidx.core.view.ViewCompat
|
|
@@ -19,6 +23,27 @@ import androidx.core.widget.NestedScrollView
|
|
|
19
23
|
|
|
20
24
|
private const val DISABLED_TEXT_ALPHA = 97
|
|
21
25
|
|
|
26
|
+
/// A button row. Knows its index and both of its colors, so a prompt can
|
|
27
|
+
/// enable or disable it while the user types (requiresText).
|
|
28
|
+
internal class OptionRow(
|
|
29
|
+
context: Context,
|
|
30
|
+
val index: Int,
|
|
31
|
+
private val enabledColor: Int,
|
|
32
|
+
private val disabledColor: Int,
|
|
33
|
+
) : AppCompatTextView(context) {
|
|
34
|
+
fun setRowEnabled(enabled: Boolean) {
|
|
35
|
+
isEnabled = enabled
|
|
36
|
+
setTextColor(if (enabled) enabledColor else disabledColor)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Every OptionRow below this view, in layout order.
|
|
41
|
+
internal fun View.optionRows(): List<OptionRow> = when (this) {
|
|
42
|
+
is OptionRow -> listOf(this)
|
|
43
|
+
is ViewGroup -> (0 until childCount).flatMap { getChildAt(it).optionRows() }
|
|
44
|
+
else -> emptyList()
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
internal fun buildContent(
|
|
23
48
|
context: Context,
|
|
24
49
|
options: ActionSheetOptions,
|
|
@@ -60,10 +85,12 @@ internal fun buildContent(
|
|
|
60
85
|
optionRows.addView(
|
|
61
86
|
buildOption(
|
|
62
87
|
context = context,
|
|
88
|
+
index = index,
|
|
63
89
|
label = label,
|
|
64
90
|
color = if (isDestructive) parseColor(options.destructiveColor) ?: palette.error else optionColor,
|
|
65
91
|
centered = centerLabels,
|
|
66
92
|
enabled = index !in options.disabledButtonIndices,
|
|
93
|
+
bold = index == options.preferredButtonIndex,
|
|
67
94
|
disabledColor = disabledColor,
|
|
68
95
|
onPress = { onSelect(index) },
|
|
69
96
|
),
|
|
@@ -77,10 +104,12 @@ internal fun buildContent(
|
|
|
77
104
|
optionRows.addView(
|
|
78
105
|
buildOption(
|
|
79
106
|
context = context,
|
|
107
|
+
index = cancelIdx,
|
|
80
108
|
label = options.options[cancelIdx],
|
|
81
109
|
color = cancelColor,
|
|
82
110
|
centered = centerLabels,
|
|
83
111
|
enabled = cancelIdx !in options.disabledButtonIndices,
|
|
112
|
+
bold = cancelIdx == options.preferredButtonIndex,
|
|
84
113
|
disabledColor = disabledColor,
|
|
85
114
|
onPress = { onSelect(cancelIdx) },
|
|
86
115
|
),
|
|
@@ -102,13 +131,45 @@ internal fun buildContent(
|
|
|
102
131
|
return container
|
|
103
132
|
}
|
|
104
133
|
|
|
105
|
-
|
|
134
|
+
/// The prompt's field, or for LOGIN_PASSWORD its login and password fields.
|
|
135
|
+
internal fun buildPromptFields(
|
|
106
136
|
context: Context,
|
|
107
137
|
options: PromptOptions,
|
|
108
138
|
palette: SheetPalette,
|
|
139
|
+
): List<EditText> {
|
|
140
|
+
val first = buildPromptField(
|
|
141
|
+
context = context,
|
|
142
|
+
palette = palette,
|
|
143
|
+
hint = options.placeholder,
|
|
144
|
+
inputType = options.keyboardType.toInputType(options.type == PromptType.SECURE_TEXT),
|
|
145
|
+
autofillHint = if (options.type == PromptType.LOGIN_PASSWORD) View.AUTOFILL_HINT_USERNAME else null,
|
|
146
|
+
).apply {
|
|
147
|
+
setText(options.defaultValue.orEmpty())
|
|
148
|
+
// Land the caret after any default value rather than before it.
|
|
149
|
+
setSelection(text?.length ?: 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (options.type != PromptType.LOGIN_PASSWORD) return listOf(first)
|
|
153
|
+
|
|
154
|
+
val password = buildPromptField(
|
|
155
|
+
context = context,
|
|
156
|
+
palette = palette,
|
|
157
|
+
hint = options.passwordPlaceholder,
|
|
158
|
+
inputType = PromptKeyboardType.DEFAULT.toInputType(secure = true),
|
|
159
|
+
autofillHint = View.AUTOFILL_HINT_PASSWORD,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return listOf(first, password)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun buildPromptField(
|
|
166
|
+
context: Context,
|
|
167
|
+
palette: SheetPalette,
|
|
168
|
+
hint: String?,
|
|
169
|
+
inputType: Int,
|
|
170
|
+
autofillHint: String?,
|
|
109
171
|
): EditText = AppCompatEditText(context).apply {
|
|
110
|
-
|
|
111
|
-
hint = options.placeholder
|
|
172
|
+
this.hint = hint
|
|
112
173
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
|
113
174
|
setTextColor(palette.primaryText)
|
|
114
175
|
setHintTextColor(ColorUtils.setAlphaComponent(palette.secondaryText, DISABLED_TEXT_ALPHA))
|
|
@@ -118,9 +179,11 @@ internal fun buildPromptInput(
|
|
|
118
179
|
// SingleLineTransformationMethod, which replaces the password masking that
|
|
119
180
|
// setInputType() sets up. Ordering them the other way renders a secure field
|
|
120
181
|
// in plain text while still reporting a password input type.
|
|
121
|
-
inputType =
|
|
122
|
-
//
|
|
123
|
-
|
|
182
|
+
this.inputType = inputType
|
|
183
|
+
// Lets the autofill service offer saved credentials, like iOS AutoFill.
|
|
184
|
+
if (autofillHint != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
185
|
+
setAutofillHints(autofillHint)
|
|
186
|
+
}
|
|
124
187
|
layoutParams = LinearLayout.LayoutParams(
|
|
125
188
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
126
189
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
@@ -146,22 +209,25 @@ private fun buildHeader(
|
|
|
146
209
|
|
|
147
210
|
private fun buildOption(
|
|
148
211
|
context: Context,
|
|
212
|
+
index: Int,
|
|
149
213
|
label: String,
|
|
150
214
|
color: Int,
|
|
151
215
|
centered: Boolean,
|
|
152
216
|
enabled: Boolean,
|
|
217
|
+
bold: Boolean,
|
|
153
218
|
disabledColor: Int,
|
|
154
219
|
onPress: () -> Unit,
|
|
155
|
-
): View =
|
|
220
|
+
): View = OptionRow(context, index, color, disabledColor).apply {
|
|
156
221
|
text = label
|
|
157
222
|
gravity = if (centered) Gravity.CENTER_HORIZONTAL else Gravity.START
|
|
158
223
|
setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16))
|
|
159
224
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
|
160
|
-
|
|
225
|
+
// The preferred button: bold, as iOS shows an alert's preferred action.
|
|
226
|
+
if (bold) setTypeface(typeface, Typeface.BOLD)
|
|
161
227
|
background = AppCompatResources.getDrawable(context, selectableItemBackgroundRes(context))
|
|
162
228
|
isClickable = true
|
|
163
229
|
isFocusable = true
|
|
164
|
-
|
|
230
|
+
setRowEnabled(enabled)
|
|
165
231
|
setOnClickListener { onPress() }
|
|
166
232
|
contentDescription = label
|
|
167
233
|
ViewCompat.setAccessibilityDelegate(
|
|
@@ -7,12 +7,16 @@ import android.graphics.Color
|
|
|
7
7
|
import android.graphics.Rect
|
|
8
8
|
import android.graphics.drawable.ColorDrawable
|
|
9
9
|
import android.graphics.drawable.GradientDrawable
|
|
10
|
+
import android.text.Editable
|
|
11
|
+
import android.text.TextWatcher
|
|
10
12
|
import android.view.Gravity
|
|
11
13
|
import android.view.View
|
|
12
14
|
import android.view.View.MeasureSpec
|
|
13
15
|
import android.view.Window
|
|
14
16
|
import android.view.WindowManager
|
|
17
|
+
import android.view.inputmethod.EditorInfo
|
|
15
18
|
import android.widget.FrameLayout
|
|
19
|
+
import android.widget.LinearLayout
|
|
16
20
|
import androidx.appcompat.app.AppCompatDialog
|
|
17
21
|
import androidx.core.graphics.Insets
|
|
18
22
|
import androidx.core.view.ViewCompat
|
|
@@ -83,29 +87,76 @@ internal object CenteredDialogPresenter : SheetPresenter {
|
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
///
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
/// What the prompt's fields held. password is empty unless LOGIN_PASSWORD.
|
|
91
|
+
internal data class PromptValues(val text: String, val password: String)
|
|
92
|
+
|
|
93
|
+
/// The centered dialog with text fields above the buttons. Deliberately not a
|
|
94
|
+
/// SheetPresenter: it takes PromptOptions and hands the fields back with the
|
|
95
|
+
/// index, which the sheet interface has no place for.
|
|
89
96
|
internal fun buildPromptDialog(
|
|
90
97
|
activity: Activity,
|
|
91
98
|
options: PromptOptions,
|
|
92
|
-
onSelect: (Dialog, Int,
|
|
93
|
-
): Pair<Dialog, () ->
|
|
99
|
+
onSelect: (Dialog, Int, PromptValues) -> Unit,
|
|
100
|
+
): Pair<Dialog, () -> PromptValues> {
|
|
94
101
|
val isDark = isDarkAppearance(activity, options.userInterfaceStyle)
|
|
95
102
|
val palette = paletteFor(isDark)
|
|
96
103
|
val dialog = AppCompatDialog(activity, dialogTheme(isDark))
|
|
97
104
|
dialog.supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
|
|
98
105
|
|
|
99
106
|
val context: Context = dialog.context
|
|
100
|
-
val
|
|
101
|
-
val
|
|
107
|
+
val fields = buildPromptFields(context, options, palette)
|
|
108
|
+
val input = fields.singleOrNull() ?: LinearLayout(context).apply {
|
|
109
|
+
orientation = LinearLayout.VERTICAL
|
|
110
|
+
fields.forEach(::addView)
|
|
111
|
+
}
|
|
112
|
+
val currentValues = {
|
|
113
|
+
PromptValues(
|
|
114
|
+
text = fields[0].text?.toString().orEmpty(),
|
|
115
|
+
password = fields.getOrNull(1)?.text?.toString().orEmpty(),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
102
118
|
|
|
103
119
|
val container = buildContent(
|
|
104
120
|
context = context,
|
|
105
121
|
options = options.toSheetOptions(),
|
|
106
122
|
palette = palette,
|
|
107
123
|
inputView = input,
|
|
108
|
-
) { index -> onSelect(dialog, index,
|
|
124
|
+
) { index -> onSelect(dialog, index, currentValues()) }
|
|
125
|
+
|
|
126
|
+
val rows = container.optionRows()
|
|
127
|
+
|
|
128
|
+
// requiresText: those buttons stay disabled while any field is empty.
|
|
129
|
+
if (options.textRequiredButtonIndices.isNotEmpty()) {
|
|
130
|
+
val update = {
|
|
131
|
+
val filled = fields.all { !it.text.isNullOrEmpty() }
|
|
132
|
+
rows.filter { it.index in options.textRequiredButtonIndices }.forEach { row ->
|
|
133
|
+
row.setRowEnabled(filled && row.index !in options.disabledButtonIndices)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
val watcher = object : TextWatcher {
|
|
137
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
|
138
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
|
139
|
+
override fun afterTextChanged(s: Editable?) = update()
|
|
140
|
+
}
|
|
141
|
+
fields.forEach { it.addTextChangedListener(watcher) }
|
|
142
|
+
// A defaultValue may already satisfy it.
|
|
143
|
+
update()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The keyboard's action key moves to the next field, then presses the
|
|
147
|
+
// preferred button, as the return key does in an iOS alert.
|
|
148
|
+
fields.forEachIndexed { index, field ->
|
|
149
|
+
field.imeOptions = if (index == fields.lastIndex) EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NEXT
|
|
150
|
+
}
|
|
151
|
+
val preferredRow = rows.firstOrNull { it.index == options.preferredButtonIndex }
|
|
152
|
+
if (preferredRow != null) {
|
|
153
|
+
fields.last().setOnEditorActionListener { _, actionId, _ ->
|
|
154
|
+
if (actionId != EditorInfo.IME_ACTION_DONE || !preferredRow.isEnabled) return@setOnEditorActionListener false
|
|
155
|
+
|
|
156
|
+
preferredRow.performClick()
|
|
157
|
+
true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
109
160
|
|
|
110
161
|
dialog.setContentView(container)
|
|
111
162
|
dialog.setCanceledOnTouchOutside(true)
|
|
@@ -127,9 +178,9 @@ internal fun buildPromptDialog(
|
|
|
127
178
|
// A prompt exists to be typed into, so raise the keyboard with it rather than
|
|
128
179
|
// making the user tap the field first.
|
|
129
180
|
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
|
130
|
-
|
|
181
|
+
fields[0].requestFocus()
|
|
131
182
|
|
|
132
|
-
return dialog to
|
|
183
|
+
return dialog to currentValues
|
|
133
184
|
}
|
|
134
185
|
|
|
135
186
|
private const val PROMPT_CORNER_RADIUS_DP = 28
|
|
@@ -147,32 +147,33 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
|
|
|
147
147
|
promise: Promise,
|
|
148
148
|
) {
|
|
149
149
|
val resolved = AtomicBoolean(false)
|
|
150
|
-
val resolveOnce: (Int?,
|
|
150
|
+
val resolveOnce: (Int?, PromptValues) -> Unit = { index, values ->
|
|
151
151
|
if (resolved.compareAndSet(false, true)) {
|
|
152
152
|
promise.resolve(
|
|
153
153
|
Arguments.createMap().apply {
|
|
154
154
|
putInt("buttonIndex", index ?: -1)
|
|
155
|
-
putString("text", text)
|
|
155
|
+
putString("text", values.text)
|
|
156
|
+
putString("password", values.password)
|
|
156
157
|
},
|
|
157
158
|
)
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
val (dialog,
|
|
162
|
+
val (dialog, currentValues) = buildPromptDialog(activity, options) { presented, index, values ->
|
|
162
163
|
presented.dismiss()
|
|
163
|
-
resolveOnce(index,
|
|
164
|
+
resolveOnce(index, values)
|
|
164
165
|
}
|
|
165
166
|
openDialogs.add(dialog)
|
|
166
167
|
|
|
167
168
|
// Read the field at dismissal time: what the user typed survives a backdrop
|
|
168
169
|
// tap, so a caller can still recover a draft.
|
|
169
|
-
dialog.setOnCancelListener { resolveOnce(options.cancelButtonIndex,
|
|
170
|
+
dialog.setOnCancelListener { resolveOnce(options.cancelButtonIndex, currentValues()) }
|
|
170
171
|
dialog.setOnDismissListener {
|
|
171
172
|
openDialogs.remove(dialog)
|
|
172
173
|
if (dismissedByApi.remove(dialog)) {
|
|
173
|
-
resolveOnce(DISMISSED_BY_API,
|
|
174
|
+
resolveOnce(DISMISSED_BY_API, currentValues())
|
|
174
175
|
} else {
|
|
175
|
-
resolveOnce(options.cancelButtonIndex,
|
|
176
|
+
resolveOnce(options.cancelButtonIndex, currentValues())
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -4,10 +4,12 @@ import android.app.Activity
|
|
|
4
4
|
import android.app.Dialog
|
|
5
5
|
import android.content.res.ColorStateList
|
|
6
6
|
import android.view.View
|
|
7
|
+
import android.view.View.MeasureSpec
|
|
7
8
|
import android.widget.LinearLayout
|
|
8
9
|
import androidx.core.view.ViewCompat
|
|
9
10
|
import androidx.core.view.WindowCompat
|
|
10
11
|
import androidx.core.view.WindowInsetsCompat
|
|
12
|
+
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
11
13
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
12
14
|
import com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
|
13
15
|
import com.google.android.material.R as MaterialR
|
|
@@ -72,16 +74,77 @@ internal object MaterialBottomSheetPresenter : SheetPresenter {
|
|
|
72
74
|
// expand behind the status bar, so cap the sheet just below it (and any
|
|
73
75
|
// display cutout); the rows scroll inside it instead.
|
|
74
76
|
val decor = activity.window.decorView
|
|
75
|
-
val
|
|
77
|
+
val insets = ViewCompat.getRootWindowInsets(decor)
|
|
78
|
+
val topInset = insets
|
|
76
79
|
?.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
|
77
80
|
?.top ?: 0
|
|
81
|
+
val bottomInset = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
|
78
82
|
if (decor.height > topInset) {
|
|
79
|
-
|
|
83
|
+
val maxHeight = decor.height - topInset
|
|
84
|
+
dialog.behavior.maxHeight = maxHeight
|
|
85
|
+
applyDetents(dialog, root, options.detents, decor, maxHeight, bottomInset)
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
return dialog
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/// The caller's detents, mapped onto Material's states. Each one becomes a
|
|
92
|
+
/// height: 'auto' the content (plus the navigation bar the sheet now draws
|
|
93
|
+
/// behind), 'medium' half the screen, 'large' everything below the status
|
|
94
|
+
/// bar. The sheet is as tall as the tallest; with two heights the lower one
|
|
95
|
+
/// is the collapsed state, and a third in between is the half-expanded one.
|
|
96
|
+
/// It opens at the first detent listed. None listed keeps Material's own
|
|
97
|
+
/// behavior: a short list fits, a long one peeks and expands.
|
|
98
|
+
private fun applyDetents(
|
|
99
|
+
dialog: BottomSheetDialog,
|
|
100
|
+
root: View,
|
|
101
|
+
detents: List<Detent>,
|
|
102
|
+
decor: View,
|
|
103
|
+
maxHeight: Int,
|
|
104
|
+
bottomInset: Int,
|
|
105
|
+
) {
|
|
106
|
+
if (detents.isEmpty()) return
|
|
107
|
+
val sheet = dialog.findViewById<View>(MaterialR.id.design_bottom_sheet) ?: return
|
|
108
|
+
|
|
109
|
+
root.measure(
|
|
110
|
+
MeasureSpec.makeMeasureSpec(decor.width, MeasureSpec.EXACTLY),
|
|
111
|
+
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST),
|
|
112
|
+
)
|
|
113
|
+
val heightOf = detents.associateWith { detent ->
|
|
114
|
+
when (detent) {
|
|
115
|
+
Detent.AUTO -> (root.measuredHeight + bottomInset).coerceAtMost(maxHeight)
|
|
116
|
+
Detent.MEDIUM -> (decor.height / 2).coerceAtMost(maxHeight)
|
|
117
|
+
Detent.LARGE -> maxHeight
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Equal heights are one resting point, e.g. 'auto' for a list that fills
|
|
121
|
+
// the screen and 'large'.
|
|
122
|
+
val heights = heightOf.values.distinct().sorted()
|
|
123
|
+
val tallest = heights.last()
|
|
124
|
+
val behavior = dialog.behavior
|
|
125
|
+
|
|
126
|
+
sheet.layoutParams = sheet.layoutParams.apply { height = tallest }
|
|
127
|
+
// Only three resting points need Material's half-expanded state, which
|
|
128
|
+
// requires fitToContents off.
|
|
129
|
+
behavior.isFitToContents = heights.size < 3
|
|
130
|
+
behavior.skipCollapsed = heights.size == 1
|
|
131
|
+
if (heights.size > 1) {
|
|
132
|
+
// Material adds the navigation bar inset to the peek height itself.
|
|
133
|
+
behavior.peekHeight = heights.first() - bottomInset
|
|
134
|
+
}
|
|
135
|
+
if (heights.size == 3) {
|
|
136
|
+
behavior.halfExpandedRatio = heights[1].toFloat() / decor.height
|
|
137
|
+
behavior.expandedOffset = decor.height - tallest
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
val initial = heightOf.getValue(detents.first())
|
|
141
|
+
behavior.state = when {
|
|
142
|
+
initial == tallest -> BottomSheetBehavior.STATE_EXPANDED
|
|
143
|
+
heights.size == 3 && initial == heights[1] -> BottomSheetBehavior.STATE_HALF_EXPANDED
|
|
144
|
+
else -> BottomSheetBehavior.STATE_COLLAPSED
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
85
148
|
private fun bottomSheetTheme(isDark: Boolean): Int = if (isDark) {
|
|
86
149
|
R.style.UnifiedActionSheet_BottomSheetDialog_Dark
|
|
87
150
|
} else {
|