Skip to content

Commit e040e24

Browse files
committed
feat(android): implement native PopupWindow context menu
Implement pure native Android context menu using only first-party Android APIs, matching the iOS UIContextMenuInteraction approach. - Add FocusMenuPopup with PopupWindow for menu display - Add FocusMenuItem data class for menu item representation - Implement GestureDetector for long press detection - Support reactions bar with bounce animations - Support menu items with icons, subtitles, and destructive styling - Support single-level submenus - Add smart positioning (above/below based on screen space) - Add entry/exit animations with OvershootInterpolator - Add background scrim with tap-to-dismiss - Remove RecyclerView dependency in favor of native LinearLayout - Fix onReactionPress callback to pass full { emoji, selected } object
1 parent d54c6c7 commit e040e24

File tree

6 files changed

+646
-569
lines changed

6 files changed

+646
-569
lines changed

android/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ android {
4141
abortOnError false
4242
}
4343
}
44+
45+
dependencies {
46+
// No third-party dependencies - uses only native Android APIs
47+
}
Lines changed: 23 additions & 251 deletions
Original file line numberDiff line numberDiff line change
@@ -1,262 +1,34 @@
11
package expo.modules.focusmenu
22

3-
import android.content.Context
4-
import android.os.Build
5-
import android.os.VibrationEffect
6-
import android.os.Vibrator
7-
import android.view.Menu
8-
import android.view.MenuItem
9-
import android.view.View
10-
import android.widget.PopupMenu
113
import expo.modules.kotlin.modules.Module
124
import expo.modules.kotlin.modules.ModuleDefinition
13-
import expo.modules.kotlin.Promise
14-
import kotlinx.coroutines.CoroutineScope
15-
import kotlinx.coroutines.Dispatchers
16-
import kotlinx.coroutines.launch
17-
import kotlinx.coroutines.withContext
185

196
class ExpoFocusMenuModule : Module() {
20-
private var currentPopupMenu: PopupMenu? = null
21-
private var menuItemCallbacks = mutableMapOf<String, (String) -> Unit>()
22-
23-
override fun definition() = ModuleDefinition {
24-
Name("ExpoFocusMenu")
25-
26-
// Define events that can be sent to JavaScript
27-
Events("onMenuItemSelected", "onMenuShown", "onMenuDismissed")
28-
29-
// Show the native context menu
30-
AsyncFunction("showNativeMenu") { items: List<Map<String, Any>>, config: Map<String, Any>?, promise: Promise ->
31-
showMenu(items, config ?: emptyMap(), promise)
32-
}
33-
34-
// Dismiss the currently visible menu
35-
AsyncFunction("dismissNativeMenu") {
36-
dismissMenu()
37-
}
38-
39-
// Check if a menu is currently visible
40-
AsyncFunction("isNativeMenuVisible") {
41-
currentPopupMenu != null
42-
}
43-
44-
// Set default configuration for menus
45-
AsyncFunction("setNativeMenuConfig") { config: Map<String, Any> ->
46-
// Store config for future use (could be stored in SharedPreferences)
47-
}
48-
49-
// Export the native view
50-
View(ExpoFocusMenuView::class) {
51-
// Define props for the view
52-
Prop("items") { view, items: List<Map<String, Any>> ->
53-
view.menuItems = items
54-
}
55-
56-
// Removed triggerMode - always use long press like iOS
57-
58-
Prop("hapticFeedback") { view, enabled: Boolean ->
59-
view.hapticFeedback = enabled
60-
}
61-
62-
Prop("reactions") { view, emojis: List<String>? ->
63-
view.reactions = emojis ?: emptyList()
64-
}
65-
66-
67-
// Event handlers
68-
Events("onItemPress", "onMenuShow", "onMenuDismiss", "onReactionPress")
69-
}
70-
}
71-
72-
private fun showMenu(items: List<Map<String, Any>>, config: Map<String, Any>, promise: Promise) {
73-
if (items.isEmpty()) {
74-
promise.reject("EMPTY_ITEMS", "Menu items cannot be empty", null)
75-
return
76-
}
77-
78-
val context = appContext.currentActivity ?: appContext.reactContext ?: run {
79-
promise.reject("NO_ACTIVITY", "No current activity available", null)
80-
return
81-
}
82-
83-
CoroutineScope(Dispatchers.Main).launch {
84-
try {
85-
// Find the root view of the current activity
86-
val rootView = context.window.decorView.findViewById<View>(android.R.id.content)
87-
88-
// Create PopupMenu
89-
val popupMenu = PopupMenu(context, rootView)
90-
currentPopupMenu = popupMenu
91-
92-
// Track menu item IDs to their string IDs
93-
val menuItemIdMap = mutableMapOf<Int, String>()
94-
var menuItemCounter = 1
95-
96-
// Add items to the menu
97-
addItemsToMenu(popupMenu.menu, items, menuItemIdMap, menuItemCounter)
98-
99-
// Handle haptic feedback
100-
val hapticFeedback = config["hapticFeedback"] as? Boolean ?: false
101-
if (hapticFeedback) {
102-
provideHapticFeedback(context)
103-
}
104-
105-
// Set up menu item click listener
106-
var itemSelected = false
107-
popupMenu.setOnMenuItemClickListener { menuItem ->
108-
val itemId = menuItemIdMap[menuItem.itemId]
109-
if (itemId != null && menuItem.isEnabled) {
110-
itemSelected = true
111-
sendEvent("onMenuItemSelected", mapOf("itemId" to itemId))
112-
promise.resolve(itemId)
113-
currentPopupMenu = null
114-
}
115-
true
116-
}
117-
118-
// Set up dismiss listener
119-
popupMenu.setOnDismissListener {
120-
if (!itemSelected) {
121-
promise.resolve(null)
122-
}
123-
sendEvent("onMenuDismissed", emptyMap())
124-
currentPopupMenu = null
125-
}
126-
127-
// Show the menu
128-
popupMenu.show()
129-
sendEvent("onMenuShown", emptyMap())
130-
131-
} catch (e: Exception) {
132-
promise.reject("MENU_ERROR", "Failed to show menu: ${e.message}", e)
133-
currentPopupMenu = null
134-
}
135-
}
136-
}
137-
138-
private fun addItemsToMenu(
139-
menu: Menu,
140-
items: List<Map<String, Any>>,
141-
menuItemIdMap: MutableMap<Int, String>,
142-
startId: Int
143-
): Int {
144-
var currentId = startId
145-
146-
for (item in items) {
147-
val id = item["id"] as? String ?: continue
148-
val title = item["title"] as? String ?: continue
149-
val subtitle = item["subtitle"] as? String
150-
val disabled = item["disabled"] as? Boolean ?: false
151-
val destructive = item["destructive"] as? Boolean ?: false
152-
val icon = item["icon"] as? String
153-
val children = item["children"] as? List<Map<String, Any>>
154-
155-
// Combine title and subtitle for display
156-
val displayTitle = if (subtitle != null) {
157-
"$title\n$subtitle"
158-
} else {
159-
title
160-
}
161-
162-
if (children != null && children.isNotEmpty()) {
163-
// Create submenu (limited to 1 level deep like iOS)
164-
val subMenu = menu.addSubMenu(displayTitle)
165-
166-
// Add icon if available (only works on some Android versions)
167-
if (icon != null) {
168-
val iconResource = getIconResource(icon)
169-
if (iconResource != 0) {
170-
subMenu.setIcon(iconResource)
171-
}
172-
}
173-
174-
// Add children to submenu (no further nesting allowed)
175-
for (child in children) {
176-
val childId = child["id"] as? String ?: continue
177-
val childTitle = child["title"] as? String ?: continue
178-
val childSubtitle = child["subtitle"] as? String
179-
val childDisabled = child["disabled"] as? Boolean ?: false
180-
181-
val childDisplayTitle = if (childSubtitle != null) {
182-
"$childTitle\n$childSubtitle"
183-
} else {
184-
childTitle
185-
}
186-
187-
val childMenuItem = subMenu.add(Menu.NONE, currentId, Menu.NONE, childDisplayTitle)
188-
menuItemIdMap[currentId] = childId
189-
currentId++
190-
childMenuItem.isEnabled = !childDisabled
191-
192-
// Add icon for child if available
193-
val childIcon = child["icon"] as? String
194-
if (childIcon != null) {
195-
val childIconResource = getIconResource(childIcon)
196-
if (childIconResource != 0) {
197-
childMenuItem.setIcon(childIconResource)
7+
override fun definition() = ModuleDefinition {
8+
Name("ExpoFocusMenu")
9+
10+
// The native view component
11+
View(ExpoFocusMenuView::class) {
12+
// Events that fire back to JS
13+
Events(
14+
"onItemPress",
15+
"onReactionPress",
16+
"onMenuShow",
17+
"onMenuDismiss"
18+
)
19+
20+
// Props from JS
21+
Prop("items") { view: ExpoFocusMenuView, items: List<Map<String, Any?>> ->
22+
view.setMenuItems(items)
19823
}
199-
}
200-
}
201-
} else {
202-
// Create regular menu item
203-
val menuItem = menu.add(Menu.NONE, currentId, Menu.NONE, displayTitle)
204-
menuItemIdMap[currentId] = id
205-
currentId++
20624

207-
// Set enabled state
208-
menuItem.isEnabled = !disabled
25+
Prop("reactions") { view: ExpoFocusMenuView, reactions: List<String>? ->
26+
view.setReactions(reactions)
27+
}
20928

210-
// Add icon if available
211-
if (icon != null) {
212-
val iconResource = getIconResource(icon)
213-
if (iconResource != 0) {
214-
menuItem.setIcon(iconResource)
215-
}
29+
Prop("hapticFeedback") { view: ExpoFocusMenuView, enabled: Boolean ->
30+
view.configureHapticFeedback(enabled)
31+
}
21632
}
217-
218-
// Note: Android doesn't have a built-in "destructive" style like iOS
219-
// You could implement custom styling if needed
220-
}
22133
}
222-
223-
return currentId
224-
}
225-
226-
private fun getIconResource(iconName: String): Int {
227-
// Map common icon names to Android drawable resources
228-
return when (iconName) {
229-
"doc.on.doc", "copy" -> android.R.drawable.ic_menu_crop
230-
"doc.on.clipboard", "paste" -> android.R.drawable.ic_menu_edit
231-
"trash", "delete" -> android.R.drawable.ic_menu_delete
232-
"arrowshape.turn.up.left", "reply" -> android.R.drawable.ic_menu_revert
233-
"arrowshape.turn.up.right", "forward" -> android.R.drawable.ic_menu_send
234-
"share" -> android.R.drawable.ic_menu_share
235-
"edit" -> android.R.drawable.ic_menu_edit
236-
"search" -> android.R.drawable.ic_menu_search
237-
"add", "plus" -> android.R.drawable.ic_menu_add
238-
"close" -> android.R.drawable.ic_menu_close_clear_cancel
239-
"info" -> android.R.drawable.ic_menu_info_details
240-
"preferences", "settings" -> android.R.drawable.ic_menu_preferences
241-
"help" -> android.R.drawable.ic_menu_help
242-
else -> 0
243-
}
244-
}
245-
246-
private fun provideHapticFeedback(context: Context) {
247-
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
248-
vibrator?.let {
249-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
250-
it.vibrate(VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE))
251-
} else {
252-
@Suppress("DEPRECATION")
253-
it.vibrate(10)
254-
}
255-
}
256-
}
257-
258-
private fun dismissMenu() {
259-
currentPopupMenu?.dismiss()
260-
currentPopupMenu = null
261-
}
262-
}
34+
}

0 commit comments

Comments
 (0)