22

The previous version of my question was too wordy. People couldn't understand it, so the following is a complete rewrite. See the edit history if you are interested in the old version.

A RelativeLayout parent sends MeasureSpecs to its child view's onMeasure method in order to see how big the child would like to be. This occurs in several passes.

My custom view

I have a custom view. As the view's content increases, the view's height increases. When the view reaches the maximum height that the parent will allow, the view's width increases for any additional content (as long as wrap_content was selected for the width). Thus, the width of the custom view is directly dependant on what parent says the maximum hight must be.

enter image description here

An (inharmonious) parent child conversation

onMeasure pass 1

The RelativeLayout parent tells my view, "You can be any width up to 900 and any height up to 600. What do you say?"

My view says, "Well, at that height, I can fit everything with a width of 100. So I'll take a width of 100 and a height of 600."

onMeasure pass 2

The RelativeLayout parent tells my view, "You told me last time that you wanted a width of 100, so let's set that as an exact width. Now, based on that width, what kind of height would you like? Anything up to 500 is OK."

"Hey!" my view replies. "If you're only giving me a maximum hight of 500, then 100 is too narrow. I need a width of 200 for that height. But fine, have it your way. I won't break the rules (yet...). I'll take a width of 100 and a height of 500."

Final result

The RelativeLayout parent assigns the view a final size of 100 for the width and 500 for the height. This is of course too narrow for the view and part of the content gets clipped.

"Sigh," thinks my view. "Why won't my parent let me be wider? There is plenty of room. Maybe someone on Stack Overflow can give me some advice."

Community
  • 1
  • 1
Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
  • I think its a matter of your units, please consider to use only one unit dp or px in width and height. – Mohamed Feb 22 '17 at 11:54
  • @Mohamed, My example here only uses px. – Suragch Feb 22 '17 at 13:17
  • When I use your custom view inside a LinearLayout it is working fine . Did you try that? – Krish Feb 22 '17 at 13:40
  • @Krish, I did. I can also get it to work fine in some situations. However, since I am making a library that will potentially be used by multiple developers, I need the custom view to work with Relative Layouts as well. – Suragch Feb 22 '17 at 14:07
  • can you make your custom view extend LinearLayout and contain TextView inside (custom view existing from two views)? – Raphau Mar 01 '17 at 12:21
  • @Raphau, I will have to think more about that. My initial reaction is that if two complex views could be merged together in such a way, then surely there is a way to get one simple view to have a similar layout behavior. – Suragch Mar 01 '17 at 12:43
  • try doing something like in this tutorial: http://trickyandroid.com/protip-inflating-layout-for-your-custom-view/ Just make your own layout.xml which is LinearLayout containing just TextView – Raphau Mar 01 '17 at 12:46
  • @Raphau, the problem with a normal `TextView` is that it only handles horizontal text. It must be rotated and mirrored to handle vertical text with LTR line wrapping. In fact, [that is what I used to do](http://stackoverflow.com/a/29739721/3681880). But I ran into further difficulties (menus and individual character rotation issues), so I finally decided to abandon `TextView` and write one from scratch. – Suragch Mar 01 '17 at 13:00
  • I guess `desiredHeight` value is the root of problem - may be it is always too large. Did you post code that calculates it? – Ircover Mar 02 '17 at 14:02
  • @Ircover, the error only occurs when the desired height is larger than the MeasureSpecs provided height (and thus never gets chosen for the final height). That's why I used a large value for `desiredHeight` in the MCVE example. – Suragch Mar 02 '17 at 15:13

2 Answers2

13

Update: Modified code to fix some things.

First, let me say that you asked a great question and laid out the problem very well (twice!) Here is my go at a solution:

It seems that there is a lot going on with onMeasure that, on the surface, doesn't make a lot of sense. Since that is the case, we will let onMeasure run as it will and at the end pass judgment on the View's bounds in onLayoutby setting mStickyWidth to the new minimum width we will accept. In onPreDraw, using a ViewTreeObserver.OnPreDrawListener, we will force another layout (requestLayout). From the documentation (emphasis added):

boolean onPreDraw ()

Callback method to be invoked when the view tree is about to be drawn. At this point, all views in the tree have been measured and given a frame. Clients can use this to adjust their scroll bounds or even to request a new layout before drawing occurs.

The new minimum width set in onLayout will now be enforced by onMeasure which is now smarter about what is possible.

I have tested this with your example code and it seems to work OK. It will need much more testing. There may be other ways to do this, but that is the gist of the approach.

CustomView.java

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewTreeObserver;

public class CustomView extends View
        implements ViewTreeObserver.OnPreDrawListener {
    private int mStickyWidth = STICKY_WIDTH_UNDEFINED;

    public CustomView(Context context) {
        super(context);
    }

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        logMeasureSpecs(widthMeasureSpec, heightMeasureSpec);
        int desiredHeight = 10000; // some value that is too high for the screen
        int desiredWidth;

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width;
        int height;

        // Height
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            height = Math.min(desiredHeight, heightSize);
        } else {
            height = desiredHeight;
        }

        // Width
        if (mStickyWidth != STICKY_WIDTH_UNDEFINED) {
            // This is the second time through layout and we are trying renogitiate a greater
            // width (mStickyWidth) without breaking the contract with the View.
            desiredWidth = mStickyWidth;
        } else if (height > BREAK_HEIGHT) { // a number between onMeasure's two final height requirements
            desiredWidth = ARBITRARY_WIDTH_LESSER; // arbitrary number
        } else {
            desiredWidth = ARBITRARY_WIDTH_GREATER; // arbitrary number
        }

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            width = Math.min(desiredWidth, widthSize);
        } else {
            width = desiredWidth;
        }

        Log.d(TAG, "setMeasuredDimension(" + width + ", " + height + ")");
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        int w = right - left;
        int h = bottom - top;

        super.onLayout(changed, left, top, right, bottom);
        // Here we need to determine if the width has been unnecessarily constrained.
        // We will try for a re-fit only once. If the sticky width is defined, we have
        // already tried to re-fit once, so we are not going to have another go at it since it
        // will (probably) have the same result.
        if (h <= BREAK_HEIGHT && (w < ARBITRARY_WIDTH_GREATER)
                && (mStickyWidth == STICKY_WIDTH_UNDEFINED)) {
            mStickyWidth = ARBITRARY_WIDTH_GREATER;
            getViewTreeObserver().addOnPreDrawListener(this);
        } else {
            mStickyWidth = STICKY_WIDTH_UNDEFINED;
        }
        Log.d(TAG, ">>>>onLayout: w=" + w + " h=" + h + " mStickyWidth=" + mStickyWidth);
    }

    @Override
    public boolean onPreDraw() {
        getViewTreeObserver().removeOnPreDrawListener(this);
        if (mStickyWidth == STICKY_WIDTH_UNDEFINED) { // Happy with the selected width.
            return true;
        }

        Log.d(TAG, ">>>>onPreDraw() requesting new layout");
        requestLayout();
        return false;
    }

    protected void logMeasureSpecs(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        String measureSpecHeight;
        String measureSpecWidth;

        if (heightMode == MeasureSpec.EXACTLY) {
            measureSpecHeight = "EXACTLY";
        } else if (heightMode == MeasureSpec.AT_MOST) {
            measureSpecHeight = "AT_MOST";
        } else {
            measureSpecHeight = "UNSPECIFIED";
        }

        if (widthMode == MeasureSpec.EXACTLY) {
            measureSpecWidth = "EXACTLY";
        } else if (widthMode == MeasureSpec.AT_MOST) {
            measureSpecWidth = "AT_MOST";
        } else {
            measureSpecWidth = "UNSPECIFIED";
        }

        Log.d(TAG, "Width: " + measureSpecWidth + ", " + widthSize + " Height: "
                + measureSpecHeight + ", " + heightSize);
    }

    private static final String TAG = "CustomView";
    private static final int STICKY_WIDTH_UNDEFINED = -1;
    private static final int BREAK_HEIGHT = 1950;
    private static final int ARBITRARY_WIDTH_LESSER = 200;
    private static final int ARBITRARY_WIDTH_GREATER = 800;
}
Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • I'm very happy to see an answer that actually addresses the problem. I was about to give up hope. – Suragch Mar 04 '17 at 00:05
  • It's an intriguing problem - hang in there. When I fight the Android framework, the framework usually wins, but we may come out ahead this time. – Cheticamp Mar 04 '17 at 00:08
  • 1
    I have tested this on both the MCVE and my actual custom view code. Both now work in the situations I described in my question. – Suragch Mar 04 '17 at 08:26
  • Good to hear. I hope it holds up as a more general solution with some tweaks. – Cheticamp Mar 04 '17 at 11:23
  • I had to add a little more checking to make sure that a relayout isn't called when the desired width is greater than the max available width, but it still seems to be working well so far. – Suragch Mar 04 '17 at 13:01
1

To make custom layout you need to read and understand this article https://developer.android.com/guide/topics/ui/how-android-draws.html

It isn't difficult to implement behaviour you want. You just need to override onMeasure and onLayout in your custom view.

In onMeasure you will get max possible height of your custom view and call measure() for childs in cycle. After child measurement get desired height from each child and calculate is child fit in current column or not, if not increase custom view wide for new column.

In onLayout you must call layout() for all child views to set them positions within the parent. This positions you have calculated in onMeasure before.

Nik
  • 7,114
  • 8
  • 51
  • 75
  • In my `onMeasure` I do calculate how wide my view should be. However, by the time I get to `onLayout` the wrong size has been selected by the `RelativeLayout` parent (not in all situations but specifically when it is `layout_below` another view and the content just barely wraps to the second column at a certain height). The actual code is [here](https://github.com/suragch/mongol-library/blob/master/mongol-library/src/main/java/net/studymongolian/mongollibrary/MongolTextView.java) (or in the edit history of my question). – Suragch Mar 03 '17 at 11:07