androidreact-nativedrop-down-menuexpomobile-development

Zeego's DropDownMenu not opens in correct direction (Android)


The direction is incorrect as it to the right side, which extends outside of the screen. I need it to open to the left side since the trigger is located on the right edge of the header (Refer to the picture)

Package versions

Steps to reproduce

Code

These codes are sourced from the example section of the Zeego documentation.

import PressableButton from "@/components/pressable/Button";
import * as Menu from "zeego/dropdown-menu";

import MenuIcon from "assets/icons/menu-dot.svg";

export default function ThreeDotMenu() {
  return (
    <Menu.Root>
      <Menu.Trigger asChild>
        <PressableButton className="flex flex-row items-center p-2.5" wrapperClassName="rounded-full">
          <MenuIcon height={20} width={20} />
        </PressableButton>
      </Menu.Trigger>
      <Menu.Content>
        <Menu.Label>Menu</Menu.Label>
        <Menu.Item key="item1">
          <Menu.ItemTitle>Item 1</Menu.ItemTitle>
        </Menu.Item>
        <Menu.Group>
          <Menu.Item key="item2">
            <Menu.ItemTitle>Item 2</Menu.ItemTitle>
          </Menu.Item>
        </Menu.Group>
        <Menu.CheckboxItem key="item3" value="off">
          <Menu.ItemTitle>Item 3</Menu.ItemTitle>
          <Menu.ItemIndicator />
        </Menu.CheckboxItem>
        <Menu.Sub>
          <Menu.SubTrigger key="item4">
            <Menu.ItemTitle>Item 4</Menu.ItemTitle>
          </Menu.SubTrigger>
          <Menu.SubContent>
            <Menu.Item key="item5">
              <Menu.ItemTitle>Item 5</Menu.ItemTitle>
            </Menu.Item>
          </Menu.SubContent>
        </Menu.Sub>
        <Menu.Separator />
        <Menu.Arrow />
      </Menu.Content>
    </Menu.Root>
  );
}

Preview

This is the outcome of my project operating on the Android platform.

Originally posted by @sumittttpaul in #113


Solution

  • Solution (Worked for me)

    Need to patch some native implementations

    Step 1 : Install patch-package

    npm install patch-package --save-dev
    

    or if you use Yarn

    yarn add patch-package --dev
    

    Step 2 : Open your package.json file and add the following script

    "scripts": {
      "postinstall": "patch-package"
    }
    

    This script will automatically apply your patch every time you run npm install or yarn.

    Step 3 : Open the file named MenuView.kt

    node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt

    Copy and paste the following code

    package com.reactnativemenu
    
    import android.content.res.ColorStateList
    import android.content.res.Resources
    import android.graphics.Color
    import android.graphics.Rect
    import android.os.Build
    import android.text.Spannable
    import android.text.SpannableStringBuilder
    import android.text.style.ForegroundColorSpan
    import android.view.*
    import androidx.appcompat.widget.PopupMenu
    import com.facebook.react.bridge.*
    import com.facebook.react.uimanager.UIManagerHelper
    import com.facebook.react.views.view.ReactViewGroup
    import java.lang.reflect.Field
    import com.facebook.react.bridge.ReactContext
    
    
    class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
        private lateinit var mActions: ReadableArray
        private var mIsAnchoredToRight = false
        private var mPopupMenu: PopupMenu? = null
        private var mIsMenuDisplayed = false
        private var mIsOnLongPress = false
        private var mGestureDetector: GestureDetector
        private var mHitSlopRect: Rect? = null
    
        init {
            mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
                override fun onLongPress(e: MotionEvent) {
                    if (!mIsOnLongPress) {
                        return
                    }
                    prepareMenu()
                }
    
                override fun onSingleTapUp(e: MotionEvent): Boolean {
                    if (!mIsOnLongPress) {
                        prepareMenu()
                    }
                    return true
                }
            })
        }
    
        fun show(){
            prepareMenu()
        }
    
        override fun setHitSlopRect(rect: Rect?) {
            super.setHitSlopRect(rect)
            mHitSlopRect = rect
            updateTouchDelegate()
        }
    
        override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
            return true
        }
    
        override fun onTouchEvent(ev: MotionEvent): Boolean {
            mGestureDetector.onTouchEvent(ev)
            return true
        }
    
        override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
            super.onSizeChanged(w, h, oldw, oldh)
            updateTouchDelegate()
        }
    
        override fun onAttachedToWindow() {
            super.onAttachedToWindow()
            updateTouchDelegate()
        }
    
        override fun onDetachedFromWindow() {
            super.onDetachedFromWindow()
            if (mIsMenuDisplayed) {
                mPopupMenu?.dismiss()
            }
        }
    
        fun setActions(actions: ReadableArray) {
            mActions = actions
        }
    
        fun setIsAnchoredToRight(isAnchoredToRight: Boolean) {
            if (mIsAnchoredToRight == isAnchoredToRight) {
                return
            }
            mIsAnchoredToRight = isAnchoredToRight
        }
    
        fun setIsOpenOnLongPress(isLongPress: Boolean) {
            mIsOnLongPress = isLongPress
        }
    
        private val getActionsCount: Int
            get() = mActions.size()
    
        private fun prepareMenuItem(menuItem: MenuItem, config: ReadableMap?) {
            val titleColor = when (config != null && config.hasKey("titleColor") && !config.isNull("titleColor")) {
                true -> config.getInt("titleColor")
                else -> null
            }
            val imageName = when (config != null && config.hasKey("image") && !config.isNull("image")) {
                true -> config.getString("image")
                else -> null
            }
            val imageColor = when (config != null && config.hasKey("imageColor") && !config.isNull("imageColor")) {
                true -> config.getInt("imageColor")
                else -> null
            }
            val attributes = when (config != null && config.hasKey("attributes") && !config.isNull(("attributes"))) {
                true -> config.getMap("attributes")
                else -> null
            }
            val subactions = when (config != null && config.hasKey("subactions") && !config.isNull(("subactions"))) {
                true -> config.getArray("subactions")
                else -> null
            }
            val menuState = config?.getString("state")
    
            if (titleColor != null) {
                menuItem.title = getMenuItemTextWithColor(menuItem.title.toString(), titleColor)
            }
    
            if (imageName != null) {
                val resourceId: Int = getDrawableIdWithName(imageName)
                if (resourceId != 0) {
                    val icon = resources.getDrawable(resourceId, context.theme)
                    if (imageColor != null) {
                        icon.setTintList(ColorStateList.valueOf(imageColor))
                    }
                    menuItem.icon = icon
                }
            }
    
            if (attributes != null) {
                // actions.attributes.disabled
                val disabled = when (attributes.hasKey("disabled") && !attributes.isNull("disabled")) {
                    true -> attributes.getBoolean("disabled")
                    else -> false
                }
                menuItem.isEnabled = !disabled
                if (!menuItem.isEnabled) {
                    val disabledColor = 0x77888888
                    menuItem.title = getMenuItemTextWithColor(menuItem.title.toString(), disabledColor)
                    if (imageName != null) {
                        val icon = menuItem.icon
                        icon?.setTintList(ColorStateList.valueOf(disabledColor))
                        menuItem.icon = icon
                    }
                }
    
                // actions.attributes.hidden
                val hidden = when (attributes.hasKey("hidden") && !attributes.isNull("hidden")) {
                    true -> attributes.getBoolean("hidden")
                    else -> false
                }
                menuItem.isVisible = !hidden
    
                // actions.attributes.destructive
                val destructive = when (attributes.hasKey("destructive") && !attributes.isNull("destructive")) {
                    true -> attributes.getBoolean("destructive")
                    else -> false
                }
                if (destructive) {
                    menuItem.title = getMenuItemTextWithColor(menuItem.title.toString(), Color.RED)
                    if (imageName != null) {
                        val icon = menuItem.icon
                        icon?.setTintList(ColorStateList.valueOf(Color.RED))
                        menuItem.icon = icon
                    }
                }
            }
    
            // Handle menuState for checkable items
            when (menuState) {
                "on", "off" -> {
                    menuItem.isCheckable = true
                    menuItem.isChecked = menuState == "on"
                }
                else -> menuItem.isCheckable = false
            }
    
            // On Android SubMenu cannot contain another SubMenu, so even if there are subactions provided
            // we are checking if item has submenu (which will occur only for 1 lvl nesting)
            if (subactions != null && menuItem.hasSubMenu()) {
                var i = 0
                val subactionsCount = subactions.size()
                while (i < subactionsCount) {
                    if (!subactions.isNull(i)) {
                        val subMenuConfig = subactions.getMap(i)
                        val subMenuItem = menuItem.subMenu?.add(Menu.NONE, Menu.NONE, i, subMenuConfig?.getString("title"))
                        if (subMenuItem != null) {
                            prepareMenuItem(subMenuItem, subMenuConfig)
                            subMenuItem.setOnMenuItemClickListener {
                                if (!it.hasSubMenu()) {
                                    mIsMenuDisplayed = false
                                    if (!subactions.isNull(it.order)) {
                                        val selectedItem = subactions.getMap(it.order)
                                        val dispatcher =
                                            UIManagerHelper.getEventDispatcherForReactTag(mContext, id)
                                        val surfaceId: Int = UIManagerHelper.getSurfaceId(this)
                                        dispatcher?.dispatchEvent(
                                            MenuOnPressActionEvent(surfaceId, id, selectedItem?.getString("id"), id)
                                        )
                                    }
                                    true
                                } else {
                                    false
                                }
                            }
                        }
                    }
                    i++
                }
            }
        }
    
        // ####### EDITED FUNCTION ########
        private fun prepareMenu() {
            if (getActionsCount > 0) {
                // Create a new PopupMenu instance each time and anchor it to this view
                val popup = PopupMenu(mContext, this)
                mPopupMenu = popup
    
                // Set gravity to END to align the right edge of the menu with the right edge of the trigger view
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    popup.gravity = Gravity.END
                }
    
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    popup.setForceShowIcon(true)
                }
                var i = 0
                while (i < getActionsCount) {
                    if (!mActions.isNull(i)) {
                        val item = mActions.getMap(i)
                        val menuItem = when (item != null && item.hasKey("subactions") && !item.isNull("subactions")) {
                            true -> popup.menu.addSubMenu(Menu.NONE, Menu.NONE, i, item.getString("title")).item
                            else -> popup.menu.add(Menu.NONE, Menu.NONE, i, item?.getString("title"))
                        }
                        prepareMenuItem(menuItem, item)
                        menuItem.setOnMenuItemClickListener {
                            if (!it.hasSubMenu()) {
                                mIsMenuDisplayed = false
                                if (!mActions.isNull(it.order)) {
                                    val selectedItem = mActions.getMap(it.order)
                                    val dispatcher =
                                        UIManagerHelper.getEventDispatcherForReactTag(mContext, id)
                                    val surfaceId: Int = UIManagerHelper.getSurfaceId(this)
                                    dispatcher?.dispatchEvent(
                                        MenuOnPressActionEvent(surfaceId, id, selectedItem?.getString("id"), id)
                                    )
                                }
                                true
                            } else {
                                false
                            }
                        }
                    }
                    i++
                }
                popup.setOnDismissListener {
                    mIsMenuDisplayed = false
                    val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(mContext, id)
                    val surfaceId: Int = UIManagerHelper.getSurfaceId(this)
                    dispatcher?.dispatchEvent(MenuOnCloseEvent(surfaceId, id, id))
                }
                mIsMenuDisplayed = true
                val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(mContext, id)
                val surfaceId: Int = UIManagerHelper.getSurfaceId(this)
                dispatcher?.dispatchEvent(MenuOnOpenEvent(surfaceId, id, id))
                popup.show()
            }
        }
    
        private fun updateTouchDelegate() {
            post {
                val hitRect = Rect()
                getHitRect(hitRect)
    
                mHitSlopRect?.let {
                    hitRect.left -= it.left
                    hitRect.top -= it.top
                    hitRect.right += it.right
                    hitRect.bottom += it.bottom
                }
    
                (parent as? ViewGroup)?.let {
                    it.touchDelegate = TouchDelegate(hitRect, this)
                }
            }
        }
    
        private fun getDrawableIdWithName(name: String): Int {
            val appResources: Resources = context.resources
            var resourceId = appResources.getIdentifier(name, "drawable", context.packageName)
            if (resourceId == 0) {
                // If drawable is not present in app's resources, check system's resources
                resourceId = getResId(name, android.R.drawable::class.java)
            }
            return resourceId
        }
    
        private fun getResId(resName: String?, c: Class<*>): Int {
            return try {
                val idField: Field = c.getDeclaredField(resName!!)
                idField.getInt(idField)
            } catch (e: Exception) {
                e.printStackTrace()
                0
            }
        }
    
        private fun getMenuItemTextWithColor(text: String, color: Int): SpannableStringBuilder {
            val textWithColor = SpannableStringBuilder()
            textWithColor.append(text)
            textWithColor.setSpan(ForegroundColorSpan(color),
                0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
            return textWithColor
        }
    }
    

    Step 4 : Finally, patch and run your build again

    First patch the @react-native-menu/menu

    npx patch-package @react-native-menu/menu
    

    It's best to start with a clean state

    If you are using Android Studio

    npx expo prebuild --clean
    

    If you are using EAS Build (Only for android using development build)

    eas build --platform android --profileĀ development