How can I get the current location of an ActionBar MenuItem?

I found another way to get a hook on an action bar button, which may be a bit simpler than the accepted solution. You can simply call findViewById, with the id of the menu item!

Now the hard part is when can you call this? In onCreate and onResume, it is too early. Well one way to do this is to use the ViewTreeObserver of the window:

    final ViewTreeObserver viewTreeObserver = getWindow().getDecorView().getViewTreeObserver();
    viewTreeObserver.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
            View menuButton = findViewById(R.id.menu_refresh);
            // This could be called when the button is not there yet, so we must test for null
            if (menuButton != null) {
                // Found it! Do what you need with the button
                int[] location = new int[2];
                menuButton.getLocationInWindow(location);
                Log.d(TAG, "x=" + location[0] + " y=" + location[1]);

                // Now you can get rid of this listener
                viewTreeObserver.removeGlobalOnLayoutListener(this);
            }
        }
    });

This goes in onCreate.

Note: this was tested on a project using ActionBarSherlock, so it is possible that it doesn't work with a 'real' action bar.


I finally found an answer to this! It is a bit hacky, but not too much. The only downsides are:

  1. You have to replace the MenuItems with your own View. An ImageView is fairly similar to the standard one but it misses features like long-pressing to see a tool-tip, and probably other things.

  2. I haven't tested it a lot.

Ok, here's how we do it. Change your Activity's onCreateOptionsMenu() to something like this:

View mAddListingButton;

@Override
public boolean onCreateOptionsMenu(Menu menu)
{
    getMenuInflater().inflate(R.menu.activity_main, menu);
    
    // Find the menu item we are interested in.
    MenuItem it = menu.findItem(R.id.item_new_listing);
    
    // Create a new view to replace the standard one.
    // It would be nice if we could just *get* the standard one
    // but unfortunately it.getActionView() returns null.
    
    // actionButtonStyle makes the 'pressed' state work.
    ImageView button = new ImageView(this, null, android.R.attr.actionButtonStyle);
    button.setImageResource(R.drawable.ic_action_add_listing);
    // The onClick attributes in the menu resource no longer work (I assume since
    // we passed null as the attribute list when creating this button.
    button.setOnClickListener(this);
    
    // Ok, now listen for when layouting is finished and the button is in position.
    button.addOnLayoutChangeListener(new OnLayoutChangeListener() {
        @Override
        public void onLayoutChange(View v, int left, int top, int right, int bottom,
                int oldLeft, int oldTop, int oldRight, int oldBottom)
        {
            // Apparently this can be called before layout is finished, so ignore it.
            // Remember also that it might never be called with sane values, for example
            // if your action button is pushed into the "more" menu thing.
            if (left == 0 && top == 0 && right == 0 && bottom == 0)
                return;
            
            // This is the only way to get the absolute position on the screen.
            // The parameters passed to this function are relative to the containing View
            // and v.getX/Y() return 0.
            int[] location = new int[2];
            v.getLocationOnScreen(location);
            
            // Ok, now we know the centre location of the button in screen coordinates!
            informButtonLocation(location[0] + (right-left)/2, location[1] + (bottom-top)/2);
        }
    });
    
    it.setActionView(button);
    mAddListingButton = it.getActionView();
    return true;
}

And then you have a function that updates your helpful instructions view with the location of the button and redraws it:

private void informButtonLocation(int cx, int cy)
{
    MyHelpView v = (MyHelpView)findViewById(R.id.instructions);
    v.setText("Click this button.");
            v.setTarget(cx, cy);
}

Finally make your fancy schmancy custom view that draws an arrow to (near) the button. In your onDraw method you can convert the screen coordinates back to Canvas coordinates using the same getLocationOnScreen function. Something like this:

@Override
public void onDraw(Canvas canvas)
{
    int[] location = new int[2];
    getLocationOnScreen(location);
    
    canvas.drawColor(Color.WHITE);

    mPaint.setColor(Color.BLACK);
    mPaint.setTextSize(40);
    mPaint.setAntiAlias(true);
    mPaint.setTextAlign(Align.CENTER);
    canvas.drawText(mText, getWidth()/2, getHeight()/2, mPaint);

    // Crappy line that isn't positioned right at all and has no arrowhead.
    if (mTargetX != -1 && mTargetY != -1)
        canvas.drawLine(getWidth()/2, getHeight()/2,
                mTargetX - location[0], mTargetY - location[1], mPaint);
}

(But obviously you have to clip the target location to the Canvas size). If you go for the same method as Google where you overlay a view over the entire screen you won't have to worry about that, but it is more intrusive. Google's method definitely doesn't use any private APIs (amazingly). Check out Cling.java in the Launcher2 code.

Incidentally, if you look at the source for Google's similar implementation of this for the launcher (they call the instructions screen a Cling for some reason), you can see that it is even more hacky. They basically use absolute screen positions determined by which layout is being used.

Hope this helps people!