react-native-unified-action-sheet 0.2.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 (51) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +44 -112
  3. package/android/build.gradle +28 -1
  4. package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +41 -2
  5. package/android/src/main/java/com/unifiedactionsheet/PromptOptions.kt +27 -5
  6. package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +75 -9
  7. package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +61 -10
  8. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +14 -7
  9. package/android/src/material/java/com/unifiedactionsheet/BottomSheetPresenterFactory.kt +10 -0
  10. package/android/src/material/java/com/unifiedactionsheet/MaterialBottomSheetPresenter.kt +153 -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 +210 -0
  15. package/ios/UnifiedActionSheet.mm +51 -5
  16. package/ios/UnifiedActionSheetImpl.swift +259 -22
  17. package/jest/index.d.ts +12 -9
  18. package/jest/index.js +37 -18
  19. package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -1
  20. package/lib/commonjs/index.js +102 -18
  21. package/lib/commonjs/index.js.map +1 -1
  22. package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
  23. package/lib/module/index.js +102 -18
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +10 -1
  26. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +5 -2
  28. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/src/common-options.interface.d.ts +9 -0
  30. package/lib/typescript/commonjs/src/common-options.interface.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/src/index.d.ts +6 -6
  32. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts +10 -6
  34. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts.map +1 -1
  35. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +10 -1
  36. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  37. package/lib/typescript/module/src/action-sheet-options.interface.d.ts +5 -2
  38. package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -1
  39. package/lib/typescript/module/src/common-options.interface.d.ts +9 -0
  40. package/lib/typescript/module/src/common-options.interface.d.ts.map +1 -1
  41. package/lib/typescript/module/src/index.d.ts +6 -6
  42. package/lib/typescript/module/src/index.d.ts.map +1 -1
  43. package/lib/typescript/module/src/prompt-options.interface.d.ts +10 -6
  44. package/lib/typescript/module/src/prompt-options.interface.d.ts.map +1 -1
  45. package/package.json +12 -2
  46. package/plugin/withUnifiedActionSheet.js +58 -0
  47. package/src/NativeUnifiedActionSheet.ts +12 -2
  48. package/src/action-sheet-options.interface.ts +12 -1
  49. package/src/common-options.interface.ts +18 -0
  50. package/src/index.tsx +147 -42
  51. package/src/prompt-options.interface.ts +21 -5
@@ -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
@@ -51,6 +51,9 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
51
51
  }
52
52
  }
53
53
 
54
+ override fun getTypedExportedConstants(): MutableMap<String, Any> =
55
+ mutableMapOf("isMaterialEnabled" to BottomSheetPresenterFactory.isAvailable)
56
+
54
57
  override fun dismissActionSheet() {
55
58
  UiThreadUtil.runOnUiThread {
56
59
  openDialogs.lastOrNull()?.let { dialog ->
@@ -111,6 +114,9 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
111
114
  CenteredDialogPresenter
112
115
  }
113
116
  }
117
+ // Without Material compiled in there is no bottom sheet; JS has already
118
+ // warned in development, from the isMaterialEnabled constant.
119
+ PresentationStyle.BOTTOM -> BottomSheetPresenterFactory.create() ?: CenteredDialogPresenter
114
120
  }
115
121
 
116
122
  val dialog = presenter.build(activity, options) { presented, index ->
@@ -141,32 +147,33 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
141
147
  promise: Promise,
142
148
  ) {
143
149
  val resolved = AtomicBoolean(false)
144
- val resolveOnce: (Int?, String) -> Unit = { index, text ->
150
+ val resolveOnce: (Int?, PromptValues) -> Unit = { index, values ->
145
151
  if (resolved.compareAndSet(false, true)) {
146
152
  promise.resolve(
147
153
  Arguments.createMap().apply {
148
154
  putInt("buttonIndex", index ?: -1)
149
- putString("text", text)
155
+ putString("text", values.text)
156
+ putString("password", values.password)
150
157
  },
151
158
  )
152
159
  }
153
160
  }
154
161
 
155
- val (dialog, currentText) = buildPromptDialog(activity, options) { presented, index, text ->
162
+ val (dialog, currentValues) = buildPromptDialog(activity, options) { presented, index, values ->
156
163
  presented.dismiss()
157
- resolveOnce(index, text)
164
+ resolveOnce(index, values)
158
165
  }
159
166
  openDialogs.add(dialog)
160
167
 
161
168
  // Read the field at dismissal time: what the user typed survives a backdrop
162
169
  // tap, so a caller can still recover a draft.
163
- dialog.setOnCancelListener { resolveOnce(options.cancelButtonIndex, currentText()) }
170
+ dialog.setOnCancelListener { resolveOnce(options.cancelButtonIndex, currentValues()) }
164
171
  dialog.setOnDismissListener {
165
172
  openDialogs.remove(dialog)
166
173
  if (dismissedByApi.remove(dialog)) {
167
- resolveOnce(DISMISSED_BY_API, currentText())
174
+ resolveOnce(DISMISSED_BY_API, currentValues())
168
175
  } else {
169
- resolveOnce(options.cancelButtonIndex, currentText())
176
+ resolveOnce(options.cancelButtonIndex, currentValues())
170
177
  }
171
178
  }
172
179
 
@@ -0,0 +1,10 @@
1
+ package com.unifiedactionsheet
2
+
3
+ /// Compiled only when the app sets unifiedActionSheet.material=true, which also
4
+ /// adds the Material dependency. The noMaterial source set defines the same
5
+ /// object, so src/main compiles against either.
6
+ internal object BottomSheetPresenterFactory {
7
+ const val isAvailable = true
8
+
9
+ fun create(): SheetPresenter? = MaterialBottomSheetPresenter
10
+ }
@@ -0,0 +1,153 @@
1
+ package com.unifiedactionsheet
2
+
3
+ import android.app.Activity
4
+ import android.app.Dialog
5
+ import android.content.res.ColorStateList
6
+ import android.view.View
7
+ import android.view.View.MeasureSpec
8
+ import android.widget.LinearLayout
9
+ import androidx.core.view.ViewCompat
10
+ import androidx.core.view.WindowCompat
11
+ import androidx.core.view.WindowInsetsCompat
12
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
13
+ import com.google.android.material.bottomsheet.BottomSheetDialog
14
+ import com.google.android.material.bottomsheet.BottomSheetDragHandleView
15
+ import com.google.android.material.R as MaterialR
16
+
17
+ /// presentationStyle 'bottom': a Material 3 modal bottom sheet. Material's own
18
+ /// behavior does the rest: a long list opens at the peek height and drags up to
19
+ /// expand, a short one opens fully, and swiping it away cancels the dialog, so
20
+ /// it resolves through the same cancel path as a backdrop tap.
21
+ internal object MaterialBottomSheetPresenter : SheetPresenter {
22
+ override fun build(
23
+ activity: Activity,
24
+ options: ActionSheetOptions,
25
+ onSelect: (Dialog, Int) -> Unit,
26
+ ): Dialog {
27
+ val isDark = isDarkAppearance(activity, options.userInterfaceStyle)
28
+ val palette = paletteFor(isDark)
29
+ // A full Material 3 theme rather than an overlay, so the sheet works inside
30
+ // an app whose own theme is plain AppCompat.
31
+ val dialog = BottomSheetDialog(activity, bottomSheetTheme(isDark))
32
+
33
+ val context = dialog.context
34
+ // The cancel row stays the last row, as on the centered dialog. With a long
35
+ // list it starts below the peek height, which Material users expect:
36
+ // swipe down, back and a backdrop tap all resolve the cancel index anyway.
37
+ val content = buildContent(context, options, palette) { index -> onSelect(dialog, index) }
38
+
39
+ val root = LinearLayout(context).apply {
40
+ orientation = LinearLayout.VERTICAL
41
+ addView(
42
+ BottomSheetDragHandleView(context),
43
+ LinearLayout.LayoutParams(
44
+ LinearLayout.LayoutParams.MATCH_PARENT,
45
+ LinearLayout.LayoutParams.WRAP_CONTENT,
46
+ ),
47
+ )
48
+ addView(
49
+ content,
50
+ LinearLayout.LayoutParams(
51
+ LinearLayout.LayoutParams.MATCH_PARENT,
52
+ LinearLayout.LayoutParams.WRAP_CONTENT,
53
+ ),
54
+ )
55
+ }
56
+ dialog.setContentView(root)
57
+ dialog.setCanceledOnTouchOutside(true)
58
+
59
+ // Tint Material's own sheet background instead of replacing it, so its
60
+ // rounded top corners and expansion animation stay intact, while the color
61
+ // matches the palette the centered and anchored styles use.
62
+ dialog.findViewById<View>(MaterialR.id.design_bottom_sheet)?.let { sheet ->
63
+ ViewCompat.setBackgroundTintList(sheet, ColorStateList.valueOf(palette.surface))
64
+ }
65
+
66
+ // The sheet now sits behind the navigation bar, so its buttons or gesture
67
+ // handle need to contrast with the sheet, not with the app underneath.
68
+ dialog.window?.let { window ->
69
+ WindowCompat.getInsetsController(window, window.decorView)
70
+ .isAppearanceLightNavigationBars = !isDark
71
+ }
72
+
73
+ // Edge to edge is for the bottom only. It would also let a long list
74
+ // expand behind the status bar, so cap the sheet just below it (and any
75
+ // display cutout); the rows scroll inside it instead.
76
+ val decor = activity.window.decorView
77
+ val insets = ViewCompat.getRootWindowInsets(decor)
78
+ val topInset = insets
79
+ ?.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
80
+ ?.top ?: 0
81
+ val bottomInset = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
82
+ if (decor.height > topInset) {
83
+ val maxHeight = decor.height - topInset
84
+ dialog.behavior.maxHeight = maxHeight
85
+ applyDetents(dialog, root, options.detents, decor, maxHeight, bottomInset)
86
+ }
87
+
88
+ return dialog
89
+ }
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
+
148
+ private fun bottomSheetTheme(isDark: Boolean): Int = if (isDark) {
149
+ R.style.UnifiedActionSheet_BottomSheetDialog_Dark
150
+ } else {
151
+ R.style.UnifiedActionSheet_BottomSheetDialog_Light
152
+ }
153
+ }
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <!-- Full Material 3 themes, not overlays: Material components refuse to
4
+ inflate under a non-Material theme, and the host app's may be plain
5
+ AppCompat. userInterfaceStyle picks light or dark explicitly, so neither
6
+ follows DayNight.
7
+
8
+ Edge to edge, so the sheet runs down behind the navigation bar instead
9
+ of stopping above it and leaving a strip of scrim there. Material only
10
+ does this when enableEdgeToEdge is set and the navigation bar is
11
+ translucent; the sheet then pads its content by the bar's inset, so the
12
+ last row stays clear of it. -->
13
+ <style name="UnifiedActionSheet.BottomSheetDialog.Light" parent="Theme.Material3.Light.BottomSheetDialog">
14
+ <item name="enableEdgeToEdge">true</item>
15
+ <item name="android:navigationBarColor">@android:color/transparent</item>
16
+ </style>
17
+
18
+ <style name="UnifiedActionSheet.BottomSheetDialog.Dark" parent="Theme.Material3.Dark.BottomSheetDialog">
19
+ <item name="enableEdgeToEdge">true</item>
20
+ <item name="android:navigationBarColor">@android:color/transparent</item>
21
+ </style>
22
+ </resources>
@@ -0,0 +1,10 @@
1
+ package com.unifiedactionsheet
2
+
3
+ /// Compiled when the app has not set unifiedActionSheet.material=true. There is
4
+ /// no bottom sheet without Material, so the module falls back to the centered
5
+ /// dialog, and JS warns in development (see getTypedExportedConstants).
6
+ internal object BottomSheetPresenterFactory {
7
+ const val isAvailable = false
8
+
9
+ fun create(): SheetPresenter? = null
10
+ }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./plugin/withUnifiedActionSheet');