Measuring children in custom view breaks in RTL

Viewed 232

I have a custom view that extends LinearLayout and implements onMeasure. I'd like the children to be either as wide as they need to be or filling the available space.

XML files: Parent:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.myapplication.AtMostLinearLayout
        android:id="@+id/at_most_linear_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</FrameLayout>

Button example:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <ImageView
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:layout_gravity="center"
        android:src="@android:drawable/ic_delete" />
</FrameLayout>

Views are added programmatically, for example:

   findViewById<AtMostLinearLayout>(R.id.at_most_linear_layout).apply {
            repeat(4) {
                LayoutInflater.from(context).inflate(R.layout.button, this)
            }
        }

Finally the Custom view class:

 
class AtMostLinearLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
    private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
    init {
        orientation = HORIZONTAL
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (childCount < 1) return

        val newWidth = min(measuredWidth, maxTotalWidth)
        var availableWidth = newWidth
        var numberOfLargeChildren = 0
        repeat(childCount) {
            getChildAt(it).let { child ->
                if (child.measuredWidth > availableWidth / childCount) {
                    availableWidth -= child.measuredWidth
                    numberOfLargeChildren++
                }
            }
        }

        val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
        repeat(childCount) {
            getChildAt(it).apply {
                measure(
                    MeasureSpec.makeMeasureSpec(max(measuredWidth, minChildWidth), EXACTLY),
                    UNSPECIFIED
                )
            }
        }

        setMeasuredDimension(
            makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
    }
}

It works fine in LTR: Left to right screenshot In RTL however the views are off set for some reason and are drawn outside the ViewGroup: Right to left screenshot

Where could this offset coming from? It looks like the children's measure calls are being added to the part, or at least half of it...

4 Answers

You could use the Layout Inspector (or "show layout boundaries" on device), in order to determine why it behaves as it does. The calculation of the horizontal offset may have to be flipped; by substracting instead of adding ...in order to account for the change in layout direction, where the absolute offset in pixels may always be understood as LTR.

If the canvas is rtl in the onDraw method, have you tried inverting it?

You could try using View.getLayoutDirection(). Return the layout direction.

onDraw method override and

val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL

if(isRtrl){
  canvas.scale(-1f, 1f, width / 2, height / 2)
}

After reading through the LinearLayout & View measure and layout code some more I figured out why this it's happening.

Whenever LinearLayout#measure is called mTotalLength is calculated, which represents the calculated width of the entire view. As I'm manually remeasuring the children with a different MeasureSpec LinearLayout cannot cache these values. Later in the layout pass the views use the cached mTotalLength to set the child's left i.e. the offset. The left is based on the gravity of the child and thus being affected by the cached value.

See: LinearLayout#onlayout

        final int layoutDirection = getLayoutDirection();
        switch (Gravity.getAbsoluteGravity(majorGravity, layoutDirection)) {
            case Gravity.RIGHT:
                // mTotalLength contains the padding already
                childLeft = mPaddingLeft + right - left - mTotalLength;
                break;

            case Gravity.CENTER_HORIZONTAL:
                // mTotalLength contains the padding already
                childLeft = mPaddingLeft + (right - left - mTotalLength) / 2;
                break;

            case Gravity.LEFT:
            default:
                childLeft = mPaddingLeft;
                break;
        }

I've change the impl to ensure it always sets the gravity to Gravity.LEFT. I should probably manually implement onLayout instead!

class AtMostLinearLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
    private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
    init {
        orientation = HORIZONTAL
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (childCount < 1) return

        val newWidth = min(measuredWidth, maxTotalWidth)
        var availableWidth = newWidth
        var numberOfLargeChildren = 0
        repeat(childCount) {
            getChildAt(it).let { child ->
                if (child.measuredWidth > availableWidth / childCount) {
                    availableWidth -= child.measuredWidth
                    numberOfLargeChildren++
                }
            }
        }

        val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
        repeat(childCount) {
            getChildAt(it).let { child ->
                child.measure(
                    makeMeasureSpec(max(child.measuredWidth, minChildWidth), EXACTLY),
                    UNSPECIFIED
                )
            }
        }

        // Effectively always set it to Gravity.LEFT to prevent LinearLayout using its
        // internally-cached mTotalLength to set the Child's left.
        gravity = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) Gravity.END else Gravity.START
        setMeasuredDimension(
            makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
    }
}

I don't understand why you need a custom ViewGroup for this work. How about set layout_weight when you add child view to LinearLayout.

Just simple by:

val layout = findViewById<LinearLayout>(R.id.linear_layout)
repeat(4) {
  val view = LayoutInflater.from(this).inflate(R.layout.button, layout, false)
  view.apply {
    updateLayoutParams<LinearLayout.LayoutParams> {
      width = 0
      weight = 1f
    }
  }
  layout.addView(view)
}
Related