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.
- package/CHANGELOG.md +87 -1
- package/README.md +44 -112
- package/android/build.gradle +28 -1
- package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +41 -2
- 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 +14 -7
- package/android/src/material/java/com/unifiedactionsheet/BottomSheetPresenterFactory.kt +10 -0
- package/android/src/material/java/com/unifiedactionsheet/MaterialBottomSheetPresenter.kt +153 -0
- package/android/src/material/res/values/unified_action_sheet_bottom_sheet.xml +22 -0
- package/android/src/noMaterial/java/com/unifiedactionsheet/BottomSheetPresenterFactory.kt +10 -0
- package/app.plugin.js +1 -0
- package/ios/BottomSheetViewController.swift +210 -0
- package/ios/UnifiedActionSheet.mm +51 -5
- package/ios/UnifiedActionSheetImpl.swift +259 -22
- 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 +102 -18
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
- package/lib/module/index.js +102 -18
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +10 -1
- package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +5 -2
- 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 +10 -1
- package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
- package/lib/typescript/module/src/action-sheet-options.interface.d.ts +5 -2
- 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 +12 -2
- package/plugin/withUnifiedActionSheet.js +58 -0
- package/src/NativeUnifiedActionSheet.ts +12 -2
- package/src/action-sheet-options.interface.ts +12 -1
- package/src/common-options.interface.ts +18 -0
- package/src/index.tsx +147 -42
- 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
|
-
|
|
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
|
|
@@ -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?,
|
|
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,
|
|
162
|
+
val (dialog, currentValues) = buildPromptDialog(activity, options) { presented, index, values ->
|
|
156
163
|
presented.dismiss()
|
|
157
|
-
resolveOnce(index,
|
|
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,
|
|
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,
|
|
174
|
+
resolveOnce(DISMISSED_BY_API, currentValues())
|
|
168
175
|
} else {
|
|
169
|
-
resolveOnce(options.cancelButtonIndex,
|
|
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');
|