0

For some reason, when modifying a ConstraintLayout's ConstraintSet programmatically to change a view position (that belongs to a chain), the result is not as expected.

In the following example I built a Button With Icon View, where the image can be positioned at the start or the end of the button. When the icon is positioned at the end, everything is fine. But when it is set to be positioned at the start of the button, its content becomes aligned to its left for no reason.

I do not know how to fix that problem. I have already tried several modifications in the code, but none of them worked.

How can it be solved?


The bugged behaviour when the icon is set to be positioned at the start of the button

The bugged behaviour when the icon is set to be positioned at the start of the button. It, somehow, becomes aligned to the left of the button


ButtonWithIconView.kt

package com.example.buttonwithimageexample

import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.res.getIntOrThrow

class ButtonWithIconView : ConstraintLayout {

    private val iconView by lazy { findViewById<ImageView>(R.id.icon) }
    private val textView by lazy { findViewById<TextView>(R.id.text) }

    /**
     * Acceptable values: Gravity.START and Gravity.END
     */
    private var iconGravity = Gravity.START

    constructor(context: Context?) : super(context) {
        commonInit(context, null)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        commonInit(context, attrs)
    }

    constructor(
        context: Context?,
        attrs: AttributeSet?,
        defStyleAttr: Int
    ) : super(context, attrs, defStyleAttr) {
        commonInit(context, attrs)
    }

    private fun commonInit(context: Context?, attrs: AttributeSet?) {
        if (context == null) {
            return
        }

        this.setBackgroundColor(Color.LTGRAY)
        this.setPadding(
            BUTTON_PADDING,
            BUTTON_PADDING,
            BUTTON_PADDING,
            BUTTON_PADDING
        )

        View.inflate(context, R.layout.button_with_icon_view, this)

        if (attrs != null) {
            applyAttrs(attrs)
        }

        if (isInEditMode) {
            return
        }
    }

    private fun applyAttrs(attrs: AttributeSet) {
        val typedArray = context.obtainStyledAttributes(
            attrs,
            R.styleable.ButtonWithIconView,
            0,
            0
        )

        if (typedArray.hasValue(R.styleable.ButtonWithIconView_button_text)) {
            textView.text = typedArray.getText(R.styleable.ButtonWithIconView_button_text)
        }

        if (typedArray.hasValue(R.styleable.ButtonWithIconView_button_icon_position)) {
            when (typedArray.getIntOrThrow(R.styleable.ButtonWithIconView_button_icon_position)) {
                ATTR_BUTTON_ICON_POS_START -> setIconPosition(Gravity.START)
                ATTR_BUTTON_ICON_POS_END -> setIconPosition(Gravity.END)
            }
        }

        typedArray.recycle()
    }

    private fun getACopyOfTheCurrentConstraintSet(): ConstraintSet {
        return ConstraintSet().apply {
            this.clone(this@ButtonWithIconView)
        }
    }

    private fun onBeforeMovingIcon(constrainSet: ConstraintSet) {
        constrainSet.removeFromHorizontalChain(textView.id)
        constrainSet.removeFromHorizontalChain(iconView.id)

        constrainSet.clear(iconView.id, ConstraintSet.LEFT)
        constrainSet.clear(iconView.id, ConstraintSet.TOP)
        constrainSet.clear(iconView.id, ConstraintSet.RIGHT)
        constrainSet.clear(iconView.id, ConstraintSet.BOTTOM)
        constrainSet.clear(iconView.id, ConstraintSet.START)
        constrainSet.clear(iconView.id, ConstraintSet.END)

        when (iconGravity) {
            Gravity.START -> {
                constrainSet.clear(
                    textView.id,
                    ConstraintSet.START
                )

                constrainSet.connect(
                    textView.id,
                    ConstraintSet.START,
                    ConstraintSet.PARENT_ID,
                    ConstraintSet.START,
                    0
                )
            }
            Gravity.END -> {
                constrainSet.clear(
                    textView.id,
                    ConstraintSet.END
                )

                constrainSet.connect(
                    textView.id,
                    ConstraintSet.END,
                    ConstraintSet.PARENT_ID,
                    ConstraintSet.END,
                    0
                )
            }
        }
    }

    private fun moveIconToLeftOfTheText() {
        val newConstraintSet = getACopyOfTheCurrentConstraintSet()

        onBeforeMovingIcon(newConstraintSet)

        newConstraintSet.clear(
            textView.id,
            ConstraintSet.START
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.END,
            textView.id,
            ConstraintSet.START,
            HALF_DISTANCE_BETWEEN_ICON_AND_TEXT
        )

        /**
         *  When this line is set, the resulting layout becomes bugged. Instead of the chain
         * being centralized in the parent, it is to the start of it =,/.
         *  Without that function call, everything works as expected, but it shouldn't, because
         * it as a chain (<left to right of> and <right to left of> are required).
         */
        newConstraintSet.connect(
            textView.id,
            ConstraintSet.START,
            iconView.id,
            ConstraintSet.END,
            HALF_DISTANCE_BETWEEN_ICON_AND_TEXT
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.START,
            ConstraintSet.PARENT_ID,
            ConstraintSet.START,
            0
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.TOP,
            ConstraintSet.PARENT_ID,
            ConstraintSet.TOP,
            0
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.BOTTOM,
            ConstraintSet.PARENT_ID,
            ConstraintSet.BOTTOM,
            0
        )

        newConstraintSet.createHorizontalChain(
            ConstraintSet.PARENT_ID,
            ConstraintSet.LEFT,
            ConstraintSet.PARENT_ID,
            ConstraintSet.RIGHT,
            intArrayOf(
                iconView.id,
                textView.id
            ),
            null,
            ConstraintSet.CHAIN_PACKED
        )

        newConstraintSet.applyTo(this)
        iconGravity = Gravity.START
    }

    private fun moveIconToTheRightOfTheText() {
        val newConstraintSet = getACopyOfTheCurrentConstraintSet()

        onBeforeMovingIcon(newConstraintSet)

        newConstraintSet.clear(
            textView.id,
            ConstraintSet.END
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.START,
            textView.id,
            ConstraintSet.END,
            HALF_DISTANCE_BETWEEN_ICON_AND_TEXT
        )

        newConstraintSet.connect(
            textView.id,
            ConstraintSet.END,
            iconView.id,
            ConstraintSet.START,
            HALF_DISTANCE_BETWEEN_ICON_AND_TEXT
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.TOP,
            ConstraintSet.PARENT_ID,
            ConstraintSet.TOP,
            0
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.END,
            ConstraintSet.PARENT_ID,
            ConstraintSet.END,
            0
        )

        newConstraintSet.connect(
            iconView.id,
            ConstraintSet.BOTTOM,
            ConstraintSet.PARENT_ID,
            ConstraintSet.BOTTOM,
            0
        )

        newConstraintSet.createHorizontalChain(
            ConstraintSet.PARENT_ID,
            ConstraintSet.LEFT,
            ConstraintSet.PARENT_ID,
            ConstraintSet.RIGHT,
            intArrayOf(
                textView.id,
                iconView.id
            ),
            null,
            ConstraintSet.CHAIN_PACKED
        )

        newConstraintSet.applyTo(this)
        iconGravity = Gravity.END
    }

    /**
     * @param gravity may be Gravity.START or Gravity.END (from the text)
     */
    fun setIconPosition(gravity: Int) {
        when (gravity) {
            Gravity.START -> moveIconToLeftOfTheText()
            Gravity.END -> moveIconToTheRightOfTheText()
            else -> throw IllegalArgumentException("Invalid gravity: $gravity")
        }
    }

    companion object {
        private val BUTTON_PADDING = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            16f,
            Resources.getSystem().displayMetrics
        ).toInt()
        private val HALF_DISTANCE_BETWEEN_ICON_AND_TEXT = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            4f,
            Resources.getSystem().displayMetrics
        ).toInt()

        private const val ATTR_BUTTON_ICON_POS_START = 0
        private const val ATTR_BUTTON_ICON_POS_END = 1
    }
}

button_with_icon_view.xml

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    tools:background="#CCCCCC"
    tools:layout_height="wrap_content"
    tools:layout_width="wrap_content"
    tools:padding="8dp"
    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">

    <ImageView
        android:id="@+id/icon"
        android:layout_width="16dp"
        android:layout_height="16dp"
        android:layout_marginRight="4dp"
        android:background="#FF0000"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/text"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="4dp"
        android:includeFontPadding="false"
        android:text="Clicker"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/icon"
        app:layout_constraintTop_toTopOf="parent" />

</merge>

attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ButtonWithIconView">
        <attr name="button_text" />
        <attr name="button_icon_position" format="enum">
            <enum name="start" value="0" />
            <enum name="end" value="1" />
        </attr>
    </declare-styleable>
</resources>

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.buttonwithimageexample.ButtonWithIconView
        android:id="@+id/left_button"
        android:layout_width="170dp"
        android:layout_height="wrap_content"
        app:button_icon_position="start"
        app:button_text="Left Button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/right_button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.example.buttonwithimageexample.ButtonWithIconView
        android:id="@+id/right_button"
        android:layout_width="170dp"
        android:layout_height="wrap_content"
        app:button_icon_position="end"
        app:button_text="Right Button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/left_button"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
Cœur
  • 37,241
  • 25
  • 195
  • 267
Augusto Carmo
  • 4,386
  • 2
  • 28
  • 61
  • Your code is a little bit verbose to follow here, but are you sure the final constraints of your set match what you need? Seems like you do clear the chain and set the start of either (depending which widget goes on the "start") to be pinned to the parent and be the chain HEAD, but It's hard to "follow" here on StackOverflow. Does this always happen? or is there a scenario where it doesn't? Is this also affected if you put a single one of your ButtonWithIconView in a layout or could the layout pass be affecting yours? – Martin Marconcini Sep 30 '19 at 14:36

1 Answers1

1

Instead of recreating constraint set from scratch programatically you have better options. Your solution is very hard to read and not easily modifiable.

1 - Create layout files for both start/end gravity and apply it inside your setGravity method:

fun setIconPosition(gravity: Int) {
    val cs = ConstraintSet()
    cs.clone(context, when (gravity) {
        Gravity.START -> R.layout.button_with_icon_view_start
        Gravity.END -> R.layout.button_with_icon_view_end
        else -> throw IllegalArgumentException("Invalid gravity: $gravity")
    })
    setConstraintSet(cs)
}

Now you no longer need incomprehensible block of code. However you will have to maintain two layout files at once if you ever want to modify the layout. So I recommend the following approach:


2 - Use Placeholders to setup the constraints and simply swap their content:

button_with_icon_view.xml

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">

    <androidx.constraintlayout.widget.Placeholder
        android:id="@+id/placeHolderStart"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="4dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/placeHolderEnd"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:content="@+id/icon"/>

    <androidx.constraintlayout.widget.Placeholder
        android:id="@+id/placeHolderEnd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/placeHolderStart"
        app:layout_constraintTop_toTopOf="parent"
        tools:content="@+id/text"/>

    <ImageView
        android:id="@+id/icon"
        android:layout_width="16dp"
        android:layout_height="16dp"
        android:background="#FF0000" />

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:includeFontPadding="false"
        android:text="Clicker" />
</merge>

Replacing the views:

fun setIconPosition(gravity : Int){
    when(gravity){
        Gravity.START -> {
            placeHolderStart.setContentId(iconView.id)
            placeHolderEnd.setContentId(textView.id)
        }
        Gravity.END -> {
            placeHolderStart.setContentId(textView.id)
            placeHolderEnd.setContentId(iconView.id)
        }
    }
    this.iconGravity = gravity
}
Pawel
  • 15,548
  • 3
  • 36
  • 36
  • What an incredible solution! I did not know about the existence of `Placeholder`, but now I know its power. Thank you very much for your answer, it was very important to me :). I'll just let the "question" open for a little while just to see if anyone knows why my code misbehave (it's really bugging me, hehe). Thanks again \o/ – Augusto Carmo Sep 30 '19 at 15:53