4

This question has been asked several times in various forms but I haven't found a definitive answer.

I need to be able to get dimensions and positions of descendant Views after the initial layout of a container (a contentView). The dimensions required in this case are of a View(Group) that is not predictable - might have multiple lines of text, minimum height to accommodate decoration, etc. I don't want to do it every on every layout pass (onLayout). The measurement I need is a from a deeply nested child, so overriding the onLayout/onMeasure of each container in between seems a poor choice. I'm not going to do anything that'll cause a loop (trigger-event-during-event).

Romain Guy hinted once that we could just .post a Runnable in the constructor of a View (which AFAIK would be in the UI thread). This seems to work on most devices (on 3 of the 4 I have personally), but fails on Nexus S. Looks like Nexus S doesn't have dimensions for everything until it's done one pass per child, and does not have the correct dimensions when the posted Runnable's run method is invoked. I tried counting layout passes (in onLayout) and comparing to getChildCount, which again works on 3 out of 4, but a different 3 (fails on Droid Razr, which seems to do just one pass - and get all measurements - regardless of the number of children). I tried various permutations of the above (posting normally; posting to a Handler; casting getContext() to an Activity and calling runOnUiThread... same results for all).

I ended up using a horrible shameful no-good hack, by checking the height of the target group against its parent's height - if it's different, it means it's been laid out. Obviously, not the best approach - but the only one that seems to work reliably between various devices and implementations. I know there's no built-in event or callback we can use, but is there a better way?

TYIA

/EDIT The bottom line for me I guess is that the Nexus S does not wait until after layout has completed when .posting() a Runnable, as is the case on all other devices I've tested, and as suggested by Romain Guy and others. I have not found a good workaround. Is there one?

momo
  • 3,885
  • 4
  • 33
  • 54

2 Answers2

9

I've been successful using the OnGlobalLayoutListener.

It worked great on my Nexus S

final LinearLayout marker = (LinearLayout) findViewById(R.id.distance_marker);
OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
        //some code using marker.getHeight(), etc.
        markersContainer.getViewTreeObserver().removeGlobalOnLayoutListener(this);
    }
};
marker.getViewTreeObserver().addOnGlobalLayoutListener(listener);
HannahMitt
  • 1,010
  • 11
  • 14
  • I need something to happen just once, after all layout has been performed. I believe OnGlobalLayoutListener fires each time there's a change - is that correct? If so, is there a way to use what you've posted to ensure that all "normal" layout is complete? Thank you. – momo Dec 19 '12 at 01:34
  • Removing the listener at the end of onGlobalLayout like I've done there means it'll only be done once. For me all normal layouts were complete in a very complex view, but I'd have to read more / know your situation better to guarantee any more. – HannahMitt Dec 19 '12 at 01:41
  • seems to work, thank you. I'm going to leave this question up for a couple days in case there's a more "standard" solution, but if not I'll accept and award. +1 for now. – momo Dec 19 '12 at 02:10
  • 1
    OnGlobalLayoutListener, your best friend and oldest trick in the book. + take a look at http://stackoverflow.com/a/6661701/833622 since measuring might be a bit faster especially when you intend to fix something and cause another invalidatio/layout. – Sherif elKhatib Dec 19 '12 at 23:41
3

My own experience with OnGlobalLayoutListener is that it does not always wait to fire until all of the child views have been laid out. So, if you're looking for the dimensions of a particular child (or child of a child, etc.) view, then if you test them within the onGlobalLayout() method, you may find that they reflect some intermediate state of the layout process, and not the final (quiescent) state that will be displayed to the user.

In testing this, I noticed that by performing a postDelayed() with a one second delay (obviously, a race condition, but this was just for testing), I did get a correct value (for a child of a child view), but if I did a simple post() with no delay, I got the wrong value.

My solution was to avoid OnGlobalLayoutListener, and instead use a straight override of dispatchDraw() in my top-level view (the one containing the child whose child I needed to measure). The default dispatchDraw() draws all of the child views of the present view, so if you post a call to your code that performs the measurements after calling super.dispatchDraw(canvas) within dispatchDraw(), then you can be sure that all child views will be drawn prior to taking your measurements.

Here is what that looks like:

@Override
protected void dispatchDraw(Canvas canvas) {
  //Draw the child views
  super.dispatchDraw(canvas);

  //All child views should have been drawn now, so we should be able to take measurements
  // of the global layout here
  post(new Runnable() {
    @Override
    public void run() {
      testAndRespondToChildViewMeasurements();
    }
  });
}

Of course, drawing will occur prior to your taking these measurements, and it will be too late to prevent that. But if that is not a problem, then this approach seems to very reliably provide the final (quiescent) measurements of the descendant views.

Once you have the measurements you need, you will want to set a flag at the top of testAndRespondToChildViewMeasurements() so that it will then return without doing anything further (or to make a similar test within the dispatchDraw() override itself), because, unlike an OnGlobalLayoutListener, there is no way to remove this override.

Carl
  • 15,445
  • 5
  • 55
  • 53