react-native-unified-action-sheet 0.1.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 (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +20 -0
  3. package/README.md +167 -0
  4. package/android/build.gradle +51 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/unifiedactionsheet/ActionSheetOptions.kt +134 -0
  7. package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +159 -0
  8. package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +214 -0
  9. package/android/src/main/java/com/unifiedactionsheet/SheetTheming.kt +56 -0
  10. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +126 -0
  11. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetPackage.kt +30 -0
  12. package/ios/UnifiedActionSheet.h +5 -0
  13. package/ios/UnifiedActionSheet.mm +96 -0
  14. package/ios/UnifiedActionSheetImpl.swift +240 -0
  15. package/jest/index.d.ts +12 -0
  16. package/jest/index.js +46 -0
  17. package/lib/commonjs/NativeUnifiedActionSheet.js +9 -0
  18. package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -0
  19. package/lib/commonjs/action-sheet-options.interface.js +2 -0
  20. package/lib/commonjs/action-sheet-options.interface.js.map +1 -0
  21. package/lib/commonjs/index.js +104 -0
  22. package/lib/commonjs/index.js.map +1 -0
  23. package/lib/commonjs/package.json +1 -0
  24. package/lib/module/NativeUnifiedActionSheet.js +5 -0
  25. package/lib/module/NativeUnifiedActionSheet.js.map +1 -0
  26. package/lib/module/action-sheet-options.interface.js +2 -0
  27. package/lib/module/action-sheet-options.interface.js.map +1 -0
  28. package/lib/module/index.js +97 -0
  29. package/lib/module/index.js.map +1 -0
  30. package/lib/module/package.json +1 -0
  31. package/lib/typescript/commonjs/package.json +1 -0
  32. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +29 -0
  33. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +29 -0
  35. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/src/index.d.ts +6 -0
  37. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  38. package/lib/typescript/module/package.json +1 -0
  39. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +29 -0
  40. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -0
  41. package/lib/typescript/module/src/action-sheet-options.interface.d.ts +29 -0
  42. package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -0
  43. package/lib/typescript/module/src/index.d.ts +6 -0
  44. package/lib/typescript/module/src/index.d.ts.map +1 -0
  45. package/package.json +155 -0
  46. package/react-native-unified-action-sheet.podspec +23 -0
  47. package/src/NativeUnifiedActionSheet.ts +29 -0
  48. package/src/action-sheet-options.interface.ts +43 -0
  49. package/src/index.tsx +131 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+
16
+ - `showActionSheetWithOptions(options)` — presents a native `UIAlertController`
17
+ on iOS and a native `AppCompatDialog` on Android through one TurboModule, and
18
+ resolves with the tapped button's index.
19
+ - Object-shaped buttons: `{ label, style?: 'cancel' | 'destructive', disabled?, onPress? }`.
20
+ `onPress` runs when that button resolves the sheet, so callers rarely match on
21
+ an index.
22
+ - `dismissActionSheet()` and `dismissAllActionSheets()` for closing sheets from
23
+ code; both resolve the affected promises with `undefined`.
24
+ - `presentationStyle: 'centered' | 'anchored'`. An `'anchored'` sheet attaches to
25
+ the `anchor` ref, which the library measures in JS — an iPad popover on iOS, a
26
+ menu-style popup on Android.
27
+ - Theming options: `userInterfaceStyle`, `tintColor`, `cancelButtonTintColor`,
28
+ `destructiveColor`, and the Android-only `buttonTextAlignment` and
29
+ `anchorAlignment`.
30
+ - A Jest mock at the `react-native-unified-action-sheet/jest` subpath, driven by
31
+ `setNextButtonIndex()` so the pressed button's `onPress` still runs.
32
+ - CommonJS and ES module builds, plus type definitions for both.
33
+
34
+ ### Notes
35
+
36
+ - Supports React Native 0.81 on both architectures through the latest release.
37
+ - No `com.google.android.material` dependency, and no runtime dependencies —
38
+ `react` and `react-native` are peers.
39
+
40
+ [unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...HEAD
41
+ [0.1.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valentyn Halkin
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # react-native-unified-action-sheet
2
+
3
+ Unified action sheet API for React Native: a native `UIAlertController` on iOS, a native `AppCompatDialog` on Android.
4
+
5
+ | Android | Android (dark mode) | iOS |
6
+ | :---: | :---: | :---: |
7
+ | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/android-demo.gif" width="280" alt="Android demo" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/android-demo-dark-mode.gif" width="280" alt="Android dark-mode demo — following the system dark theme" /> | <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/ios-demo.gif" width="280" alt="iOS demo" /> |
8
+
9
+ <p align="center">
10
+ <img src="https://raw.githubusercontent.com/galikvalkin/react-native-unified-action-sheet/HEAD/docs/ipad-demo.gif" width="420" alt="iPad demo — the sheet as a popover anchored to the button that opened it" />
11
+ <br />
12
+ <em>iPad: the same sheet presented as a popover, anchored to the view that opened it.</em>
13
+ </p>
14
+
15
+ - **Fully native, always on top.** The sheet gets its own platform window, so it renders above your entire React Native view tree — including above an already-open [`Modal`](https://reactnative.dev/docs/modal). JS-rendered action sheets live *inside* the component tree, where `overflow`, `zIndex`, a transform or an open modal can clip or bury them.
16
+ - **One API, native on both platforms**, so the same options behave the same way.
17
+ - Works on React Native **0.81 (old and new architecture)** through the **latest** release.
18
+ - **No extra native dependencies** on either platform, and zero runtime dependencies (`react` / `react-native` peers only).
19
+
20
+ ## Installation
21
+
22
+ ```sh
23
+ npm install react-native-unified-action-sheet
24
+ # or
25
+ yarn add react-native-unified-action-sheet
26
+ ```
27
+
28
+ Autolinking handles the rest on both platforms. In Expo apps, use a development build (`npx expo run:ios` / `npx expo run:android`) — custom native modules do not work in **Expo Go**.
29
+
30
+ ## Usage
31
+
32
+ ```ts
33
+ import { showActionSheetWithOptions } from 'react-native-unified-action-sheet';
34
+
35
+ await showActionSheetWithOptions({
36
+ title: 'Delete item?',
37
+ message: 'This action cannot be undone.',
38
+ options: [
39
+ { label: 'Delete', style: 'destructive', onPress: deleteItem },
40
+ { label: 'Archive', onPress: archiveItem },
41
+ { label: 'Unavailable', disabled: true },
42
+ { label: 'Cancel', style: 'cancel' },
43
+ ],
44
+ });
45
+ ```
46
+
47
+ Each button is an object. `style` marks the two native roles, which are mutually exclusive; `disabled` is independent of it; and `onPress` runs when that button resolves the sheet, so reordering buttons cannot send one down another's branch. The call also resolves with the tapped index, and the two never disagree:
48
+
49
+ | | promise resolves with | `onPress` runs |
50
+ | --- | --- | --- |
51
+ | Tapping a button | its index | that button's |
52
+ | Backdrop tap, back button, swipe down | the `'cancel'` button's index, or `-1` if there is none | the cancel button's, if there is one |
53
+ | `dismissActionSheet()` | `undefined` | nothing |
54
+
55
+ The promise never rejects on its own — only if one of your own `onPress` handlers throws.
56
+
57
+ ### Anchored sheets
58
+
59
+ `presentationStyle: 'anchored'` attaches the sheet to a view. Pass the ref itself; the library measures it and sends only the resulting rectangle across:
60
+
61
+ ```tsx
62
+ const anchorRef = useRef<View>(null);
63
+
64
+ <Pressable
65
+ ref={anchorRef}
66
+ onPress={() =>
67
+ showActionSheetWithOptions({
68
+ options: [...],
69
+ presentationStyle: 'anchored',
70
+ anchor: anchorRef,
71
+ })
72
+ }
73
+ />;
74
+ ```
75
+
76
+ Anything with a `measureInWindow` method works, which is every React Native host component ref. On iOS the anchor only applies on iPad, where the sheet becomes a popover; iPhone always presents from the bottom.
77
+
78
+ ### Dismissing from code
79
+
80
+ `dismissActionSheet()` closes the most recently opened sheet, `dismissAllActionSheets()` closes every open one. Both are no-ops when nothing is open, and the dismissed sheets resolve with `undefined`.
81
+
82
+ ```ts
83
+ import {
84
+ dismissActionSheet,
85
+ dismissAllActionSheets,
86
+ } from 'react-native-unified-action-sheet';
87
+ ```
88
+
89
+ ### Options
90
+
91
+ | Option | Type | iOS | Android | Description |
92
+ | --- | --- | :---: | :---: | --- |
93
+ | `options` | `ActionSheetButtonInterface[]` | ✅ | ✅ | The buttons, in order: `{ label, style?, disabled?, onPress? }`. |
94
+ | `style` (per button) | `'cancel' \| 'destructive'?` | ✅ | ✅ | `'cancel'` renders a separated cancel row on Android and resolves on backdrop tap / back; `'destructive'` renders in the destructive color. Only the first `'cancel'` counts. |
95
+ | `disabled` (per button) | `boolean?` | ✅ | ✅ | Renders the row dimmed and unresponsive to taps. |
96
+ | `onPress` (per button) | `() => void?` | ✅ | ✅ | Runs when this button resolves the sheet. |
97
+ | `title` | `string?` | ✅ | ✅ | Sheet title. |
98
+ | `message` | `string?` | ✅ | ✅ | Secondary text under the title. |
99
+ | `presentationStyle` | `'centered' \| 'anchored'` | ✅ | ✅ | `'centered'` is a centered dialog; `'anchored'` attaches to `anchor` (an iPad popover on iOS). **Defaults differ**: Android defaults to `'centered'`, iOS to the standard action sheet. |
100
+ | `anchor` | `ActionSheetAnchorInterface?` | ✅ | ✅ | The view to attach to — a ref, or anything with `measureInWindow`. Without a measurable anchor an `'anchored'` sheet falls back to a centered dialog. |
101
+ | `anchorAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of an `'anchored'` popup relative to its anchor. `'start'` (default) aligns leading edges, flipping in RTL. |
102
+ | `userInterfaceStyle` | `'light' \| 'dark'` | ✅ | ✅ | Forces the appearance; defaults to following the system setting. |
103
+ | `tintColor` | `string?` | ✅ | ✅ | Text color of non-destructive buttons. |
104
+ | `cancelButtonTintColor` | `string?` | ✅ | ✅ | Text color of the cancel button; overrides `tintColor` for that row. |
105
+ | `destructiveColor` | `string?` | ✅ | ✅ | Overrides the destructive row color (Android's palette error color, iOS system red). |
106
+ | `buttonTextAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of button labels. Defaults to `'start'`, which follows layout direction. |
107
+
108
+ The options types are exported for typing your own wrappers:
109
+
110
+ ```ts
111
+ import type {
112
+ ActionSheetOptionsInterface, // what showActionSheetWithOptions accepts
113
+ ActionSheetCommonOptionsInterface,
114
+ ActionSheetAndroidOptionsInterface,
115
+ ActionSheetButtonInterface, // a single button
116
+ ActionSheetAnchorInterface, // anything with measureInWindow
117
+ } from 'react-native-unified-action-sheet';
118
+ ```
119
+
120
+ ### Platform notes
121
+
122
+ - **Which gestures dismiss differs.** On iOS a sheet can only be tapped away if it has a `'cancel'` button, and a `'centered'` one never can — UIKit treats it as strictly modal. Android's centered dialog always cancels on a backdrop tap. Give a sheet a cancel button if you want that gesture everywhere.
123
+ - **On iPad, a popover hides the cancel row**, since tapping outside already cancels. The index you receive is unaffected.
124
+ - **Sheets stack.** Opening one over another puts it on top, and each resolves its own promise. Opening a sheet over a `Modal` does not dismiss the modal.
125
+ - **Light or dark follows the system setting** unless `userInterfaceStyle` forces one, chosen when the sheet opens. The sheet uses its own palette, so it looks the same in any host app.
126
+ - **Long lists scroll**, with the title and message pinned above them.
127
+ - On Android, a sheet with no available activity resolves the cancel index rather than hanging, so a cancel handler can run for a sheet that never appeared.
128
+
129
+ ## Testing
130
+
131
+ The package ships a Jest mock, so a screen that opens a sheet can be tested without the native module:
132
+
133
+ ```ts
134
+ jest.mock('react-native-unified-action-sheet', () =>
135
+ require('react-native-unified-action-sheet/jest')
136
+ );
137
+ ```
138
+
139
+ Every sheet then resolves with no selection. To simulate a tap, queue the index the next sheet should resolve with — the pressed button's `onPress` runs, just as it would for real:
140
+
141
+ ```ts
142
+ import { setNextButtonIndex } from 'react-native-unified-action-sheet/jest';
143
+
144
+ setNextButtonIndex(0);
145
+ await openTheSheet();
146
+ ```
147
+
148
+ Prefer that over `mockResolvedValueOnce`, which replaces the implementation and so skips `onPress`. `dismissActionSheet` and `dismissAllActionSheets` are plain spies.
149
+
150
+ ## Compatibility
151
+
152
+ | React Native | Old architecture | New architecture |
153
+ | --- | --- | --- |
154
+ | 0.81.x | ✅ Android verified | ✅ Android verified |
155
+ | 0.85.x (latest line) | n/a (removed in RN 0.82+) | ✅ verified (iOS + Android) |
156
+
157
+ **iOS on RN 0.81 is unverified.** The pod integrates, but React Native 0.81 itself does not build under Xcode 26 — a toolchain clash unrelated to this library.
158
+
159
+ ## Contributing
160
+
161
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
162
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
163
+ - [Code of conduct](CODE_OF_CONDUCT.md)
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,51 @@
1
+ buildscript {
2
+ ext.UnifiedActionSheet = [
3
+ kotlinVersion: "2.0.21",
4
+ minSdkVersion: 24,
5
+ compileSdkVersion: 36
6
+ ]
7
+
8
+ ext.getExtOrDefault = { prop ->
9
+ if (rootProject.ext.has(prop)) {
10
+ return rootProject.ext.get(prop)
11
+ }
12
+
13
+ return UnifiedActionSheet[prop]
14
+ }
15
+
16
+ repositories {
17
+ google()
18
+ mavenCentral()
19
+ }
20
+
21
+ dependencies {
22
+ classpath "com.android.tools.build:gradle:8.7.2"
23
+ // noinspection DifferentKotlinGradleVersion
24
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
25
+ }
26
+ }
27
+
28
+
29
+ apply plugin: "com.android.library"
30
+ apply plugin: "kotlin-android"
31
+
32
+ apply plugin: "com.facebook.react"
33
+
34
+ android {
35
+ namespace "com.unifiedactionsheet"
36
+
37
+ compileSdkVersion getExtOrDefault("compileSdkVersion")
38
+
39
+ defaultConfig {
40
+ minSdkVersion getExtOrDefault("minSdkVersion")
41
+ }
42
+
43
+ compileOptions {
44
+ sourceCompatibility JavaVersion.VERSION_17
45
+ targetCompatibility JavaVersion.VERSION_17
46
+ }
47
+ }
48
+
49
+ dependencies {
50
+ implementation "com.facebook.react:react-android"
51
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,134 @@
1
+ package com.unifiedactionsheet
2
+
3
+ import android.graphics.Rect
4
+ import com.facebook.react.bridge.ReadableMap
5
+
6
+ internal enum class PresentationStyle {
7
+ CENTERED,
8
+ ANCHORED,
9
+ ;
10
+
11
+ companion object {
12
+ fun fromWire(value: String?): PresentationStyle =
13
+ if (value == "anchored") ANCHORED else CENTERED
14
+ }
15
+ }
16
+
17
+ internal enum class ButtonTextAlignment {
18
+ START,
19
+ CENTER,
20
+ ;
21
+
22
+ companion object {
23
+ fun fromWire(value: String?): ButtonTextAlignment =
24
+ if (value == "center") CENTER else START
25
+ }
26
+ }
27
+
28
+ internal enum class AnchorAlignment {
29
+ START,
30
+ CENTER,
31
+ ;
32
+
33
+ companion object {
34
+ fun fromWire(value: String?): AnchorAlignment =
35
+ if (value == "center") CENTER else START
36
+ }
37
+ }
38
+
39
+ internal enum class ForcedAppearance {
40
+ SYSTEM,
41
+ LIGHT,
42
+ DARK,
43
+ ;
44
+
45
+ companion object {
46
+ fun fromWire(value: String?): ForcedAppearance = when (value) {
47
+ "light" -> LIGHT
48
+ "dark" -> DARK
49
+ else -> SYSTEM
50
+ }
51
+ }
52
+ }
53
+
54
+ internal data class ActionSheetOptions(
55
+ val options: List<String>,
56
+ val cancelButtonIndex: Int?,
57
+ val destructiveButtonIndices: Set<Int>,
58
+ val title: String?,
59
+ val message: String?,
60
+ val tintColor: String?,
61
+ val cancelButtonTintColor: String?,
62
+ val destructiveColor: String?,
63
+ val buttonTextAlignment: ButtonTextAlignment,
64
+ val disabledButtonIndices: Set<Int>,
65
+ val userInterfaceStyle: ForcedAppearance,
66
+ val presentationStyle: PresentationStyle,
67
+ val anchorRect: Rect?,
68
+ val anchorAlignment: AnchorAlignment,
69
+ ) {
70
+ companion object {
71
+ /// density converts the anchor rect: measureInWindow reports dp, while
72
+ /// View coordinates are px.
73
+ fun fromReadableMap(map: ReadableMap, density: Float): ActionSheetOptions {
74
+ val labels = mutableListOf<String>()
75
+ map.getArray("options")?.let { array ->
76
+ for (index in 0 until array.size()) {
77
+ array.getString(index)?.let(labels::add)
78
+ }
79
+ }
80
+
81
+ val disabledIndices = mutableSetOf<Int>()
82
+ map.getArray("disabledButtonIndices")?.let { array ->
83
+ for (index in 0 until array.size()) {
84
+ disabledIndices.add(array.getInt(index))
85
+ }
86
+ }
87
+
88
+ val destructiveIndices = mutableSetOf<Int>()
89
+ map.getArray("destructiveButtonIndices")?.let { array ->
90
+ for (index in 0 until array.size()) {
91
+ destructiveIndices.add(array.getInt(index))
92
+ }
93
+ }
94
+
95
+ return ActionSheetOptions(
96
+ options = labels,
97
+ cancelButtonIndex = optInt(map, "cancelButtonIndex"),
98
+ destructiveButtonIndices = destructiveIndices,
99
+ title = optString(map, "title"),
100
+ message = optString(map, "message"),
101
+ tintColor = optString(map, "tintColor"),
102
+ cancelButtonTintColor = optString(map, "cancelButtonTintColor"),
103
+ destructiveColor = optString(map, "destructiveColor"),
104
+ buttonTextAlignment = ButtonTextAlignment.fromWire(optString(map, "buttonTextAlignment")),
105
+ disabledButtonIndices = disabledIndices,
106
+ userInterfaceStyle = ForcedAppearance.fromWire(optString(map, "userInterfaceStyle")),
107
+ presentationStyle = PresentationStyle.fromWire(optString(map, "presentationStyle")),
108
+ anchorRect = optRect(map, "anchorRect", density),
109
+ anchorAlignment = AnchorAlignment.fromWire(optString(map, "anchorAlignment")),
110
+ )
111
+ }
112
+
113
+ private fun optRect(map: ReadableMap, key: String, density: Float): Rect? {
114
+ if (!map.hasKey(key) || map.isNull(key)) return null
115
+ val rect = map.getMap(key) ?: return null
116
+
117
+ val x = rect.getDouble("x") * density
118
+ val y = rect.getDouble("y") * density
119
+
120
+ return Rect(
121
+ x.toInt(),
122
+ y.toInt(),
123
+ (x + rect.getDouble("width") * density).toInt(),
124
+ (y + rect.getDouble("height") * density).toInt(),
125
+ )
126
+ }
127
+
128
+ private fun optInt(map: ReadableMap, key: String): Int? =
129
+ if (map.hasKey(key) && !map.isNull(key)) map.getInt(key) else null
130
+
131
+ private fun optString(map: ReadableMap, key: String): String? =
132
+ if (map.hasKey(key) && !map.isNull(key)) map.getString(key) else null
133
+ }
134
+ }
@@ -0,0 +1,159 @@
1
+ package com.unifiedactionsheet
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.util.TypedValue
6
+ import android.view.Gravity
7
+ import android.view.View
8
+ import android.widget.Button
9
+ import android.widget.LinearLayout
10
+ import android.widget.TextView
11
+ import androidx.appcompat.content.res.AppCompatResources
12
+ import androidx.core.graphics.ColorUtils
13
+ import androidx.core.view.AccessibilityDelegateCompat
14
+ import androidx.core.view.ViewCompat
15
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
16
+ import androidx.core.widget.NestedScrollView
17
+
18
+ private const val DISABLED_TEXT_ALPHA = 97
19
+
20
+ internal fun buildContent(
21
+ context: Context,
22
+ options: ActionSheetOptions,
23
+ palette: SheetPalette,
24
+ includeCancelRow: Boolean = true,
25
+ onSelect: (Int) -> Unit,
26
+ ): LinearLayout {
27
+ val container = LinearLayout(context).apply {
28
+ orientation = LinearLayout.VERTICAL
29
+ setPadding(dp(context, 8), dp(context, 8), dp(context, 8), dp(context, 16))
30
+ }
31
+
32
+ options.title?.takeIf { it.isNotBlank() }?.let {
33
+ container.addView(buildHeader(context, it, palette.secondaryText, isTitle = true))
34
+ }
35
+ options.message?.takeIf { it.isNotBlank() }?.let {
36
+ container.addView(buildHeader(context, it, palette.secondaryText, isTitle = false))
37
+ }
38
+
39
+ val centerLabels = options.buttonTextAlignment == ButtonTextAlignment.CENTER
40
+
41
+ val optionColor = parseColor(options.tintColor) ?: palette.primaryText
42
+ val cancelColor = parseColor(options.cancelButtonTintColor) ?: optionColor
43
+ // Disabled rows drop their role color for a neutral dim, matching iOS, where
44
+ // UIKit owns the appearance of a disabled action.
45
+ val disabledColor = ColorUtils.setAlphaComponent(palette.primaryText, DISABLED_TEXT_ALPHA)
46
+
47
+ val optionRows = LinearLayout(context).apply {
48
+ orientation = LinearLayout.VERTICAL
49
+ }
50
+
51
+ options.options.forEachIndexed { index, label ->
52
+ if (index == options.cancelButtonIndex) return@forEachIndexed
53
+ val isDestructive = index in options.destructiveButtonIndices
54
+ optionRows.addView(
55
+ buildOption(
56
+ context = context,
57
+ label = label,
58
+ color = if (isDestructive) parseColor(options.destructiveColor) ?: palette.error else optionColor,
59
+ centered = centerLabels,
60
+ enabled = index !in options.disabledButtonIndices,
61
+ disabledColor = disabledColor,
62
+ onPress = { onSelect(index) },
63
+ ),
64
+ )
65
+ }
66
+
67
+ if (includeCancelRow) {
68
+ options.cancelButtonIndex?.let { cancelIdx ->
69
+ if (cancelIdx in options.options.indices) {
70
+ optionRows.addView(buildSpacer(context))
71
+ optionRows.addView(
72
+ buildOption(
73
+ context = context,
74
+ label = options.options[cancelIdx],
75
+ color = cancelColor,
76
+ centered = centerLabels,
77
+ enabled = cancelIdx !in options.disabledButtonIndices,
78
+ disabledColor = disabledColor,
79
+ onPress = { onSelect(cancelIdx) },
80
+ ),
81
+ )
82
+ }
83
+ }
84
+ }
85
+
86
+ val scroll = NestedScrollView(context).apply {
87
+ addView(optionRows)
88
+ layoutParams = LinearLayout.LayoutParams(
89
+ LinearLayout.LayoutParams.MATCH_PARENT,
90
+ LinearLayout.LayoutParams.WRAP_CONTENT,
91
+ 1f,
92
+ )
93
+ }
94
+ container.addView(scroll)
95
+
96
+ return container
97
+ }
98
+
99
+ private fun buildHeader(
100
+ context: Context,
101
+ text: String,
102
+ color: Int,
103
+ isTitle: Boolean,
104
+ ): TextView =
105
+ TextView(context).apply {
106
+ this.text = text
107
+ gravity = Gravity.CENTER
108
+ setPadding(dp(context, 16), dp(context, 12), dp(context, 16), dp(context, 8))
109
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, if (isTitle) 14f else 12f)
110
+ setTextColor(color)
111
+ if (isTitle) {
112
+ ViewCompat.setAccessibilityHeading(this, true)
113
+ }
114
+ }
115
+
116
+ private fun buildOption(
117
+ context: Context,
118
+ label: String,
119
+ color: Int,
120
+ centered: Boolean,
121
+ enabled: Boolean,
122
+ disabledColor: Int,
123
+ onPress: () -> Unit,
124
+ ): View = TextView(context).apply {
125
+ text = label
126
+ gravity = if (centered) Gravity.CENTER_HORIZONTAL else Gravity.START
127
+ setPadding(dp(context, 16), dp(context, 16), dp(context, 16), dp(context, 16))
128
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
129
+ setTextColor(if (enabled) color else disabledColor)
130
+ background = AppCompatResources.getDrawable(context, selectableItemBackgroundRes(context))
131
+ isClickable = true
132
+ isFocusable = true
133
+ isEnabled = enabled
134
+ setOnClickListener { onPress() }
135
+ contentDescription = label
136
+ ViewCompat.setAccessibilityDelegate(
137
+ this,
138
+ object : AccessibilityDelegateCompat() {
139
+ override fun onInitializeAccessibilityNodeInfo(
140
+ host: View,
141
+ info: AccessibilityNodeInfoCompat,
142
+ ) {
143
+ super.onInitializeAccessibilityNodeInfo(host, info)
144
+ info.className = Button::class.java.name
145
+ }
146
+ },
147
+ )
148
+ }
149
+
150
+ private fun buildSpacer(context: Context): View = View(context).apply {
151
+ layoutParams = LinearLayout.LayoutParams(
152
+ LinearLayout.LayoutParams.MATCH_PARENT,
153
+ dp(context, 8),
154
+ )
155
+ }
156
+
157
+ private fun parseColor(value: String?): Int? = value?.let {
158
+ runCatching { Color.parseColor(it) }.getOrNull()
159
+ }