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.
Files changed (45) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +24 -4
  3. package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +35 -0
  4. package/android/src/main/java/com/unifiedactionsheet/PromptOptions.kt +27 -5
  5. package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +75 -9
  6. package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +61 -10
  7. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +8 -7
  8. package/android/src/material/java/com/unifiedactionsheet/MaterialBottomSheetPresenter.kt +65 -2
  9. package/ios/BottomSheetViewController.swift +3 -1
  10. package/ios/UnifiedActionSheet.mm +25 -4
  11. package/ios/UnifiedActionSheetImpl.swift +124 -27
  12. package/jest/index.d.ts +12 -9
  13. package/jest/index.js +37 -18
  14. package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -1
  15. package/lib/commonjs/index.js +84 -17
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
  18. package/lib/module/index.js +84 -17
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +7 -1
  21. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +4 -1
  23. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/src/common-options.interface.d.ts +9 -0
  25. package/lib/typescript/commonjs/src/common-options.interface.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/src/index.d.ts +6 -6
  27. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts +10 -6
  29. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts.map +1 -1
  30. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +7 -1
  31. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  32. package/lib/typescript/module/src/action-sheet-options.interface.d.ts +4 -1
  33. package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -1
  34. package/lib/typescript/module/src/common-options.interface.d.ts +9 -0
  35. package/lib/typescript/module/src/common-options.interface.d.ts.map +1 -1
  36. package/lib/typescript/module/src/index.d.ts +6 -6
  37. package/lib/typescript/module/src/index.d.ts.map +1 -1
  38. package/lib/typescript/module/src/prompt-options.interface.d.ts +10 -6
  39. package/lib/typescript/module/src/prompt-options.interface.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/NativeUnifiedActionSheet.ts +7 -2
  42. package/src/action-sheet-options.interface.ts +11 -0
  43. package/src/common-options.interface.ts +18 -0
  44. package/src/index.tsx +112 -39
  45. 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.3.0...HEAD
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 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).
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. Dismissed sheets resolve with `undefined`.
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 secureTextEntry: Boolean,
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
- secureTextEntry = optBoolean(map, "secureTextEntry"),
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
- internal fun buildPromptInput(
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
- setText(options.defaultValue.orEmpty())
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 = options.keyboardType.toInputType(options.secureTextEntry)
122
- // Land the caret after any default value rather than before it.
123
- setSelection(text?.length ?: 0)
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 = TextView(context).apply {
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
- setTextColor(if (enabled) color else disabledColor)
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
- isEnabled = enabled
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
- /// 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.
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, String) -> Unit,
93
- ): Pair<Dialog, () -> String> {
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 input = buildPromptInput(context, options, palette)
101
- val currentText = { input.text?.toString().orEmpty() }
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, currentText()) }
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
- input.requestFocus()
181
+ fields[0].requestFocus()
131
182
 
132
- return dialog to currentText
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?, String) -> Unit = { index, text ->
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, currentText) = buildPromptDialog(activity, options) { presented, index, text ->
162
+ val (dialog, currentValues) = buildPromptDialog(activity, options) { presented, index, values ->
162
163
  presented.dismiss()
163
- resolveOnce(index, text)
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, currentText()) }
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, currentText())
174
+ resolveOnce(DISMISSED_BY_API, currentValues())
174
175
  } else {
175
- resolveOnce(options.cancelButtonIndex, currentText())
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 topInset = ViewCompat.getRootWindowInsets(decor)
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
- dialog.behavior.maxHeight = decor.height - topInset
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 {