How to animate TextView width from Visible to Gone state?

Viewed 61

I want to animate TextView width when I change visibility of TextView. I don't wanna achieve generic "fade in/out" effect, but I wanna collapse TextView from sides to 0 width.

Here are my functions:

fun fadeInTextViewSize(){
    val parentWidth = (buttonText.parent as View).measuredWidth
    val widthAnimator = ValueAnimator.ofInt(buttonText.width, parentWidth)
    widthAnimator.duration = 500
    widthAnimator.interpolator = DecelerateInterpolator()
    widthAnimator.addUpdateListener { animation ->
        buttonText.layoutParams.width = animation.animatedValue as Int
        buttonText.requestLayout()
    }
    widthAnimator.start()
}

fun fadeOutTextViewSize(){
    val widthAnimator = ValueAnimator.ofInt(buttonText.width, 0)
    widthAnimator.duration = 500
    widthAnimator.interpolator = DecelerateInterpolator()
    widthAnimator.addUpdateListener { animation ->
        buttonText.layoutParams.width = animation.animatedValue as Int
        buttonText.requestLayout()
    }
    widthAnimator.start()
}

Issue is that with this function, my TextView height is for some reason set to MATCH_PARENT.

1 Answers

I assume that you need the TextView to appear as if erased from both ends equally. Here is a technique that will do that:

private lateinit var buttonText: TextView
private var viewWidth = 0

fun fadeOutTextViewSize() {
    with(buttonText) {
        // Enable scrolling for the view since we will need to scroll horizontally to center text.
        setMovementMethod(ScrollingMovementMethod())
        setHorizontallyScrolling(true)

        // Lock in the starting width and height.
        viewWidth = buttonText.width
        layoutParams.width = buttonText.width
        layoutParams.height = buttonText.height

        visibility = View.VISIBLE
    }

    with(ValueAnimator.ofInt(viewWidth, 0)) {
        duration = 1000
        interpolator = DecelerateInterpolator()

        addUpdateListener { animation ->
            val newWidth = animation.animatedValue as Int
            buttonText.layoutParams.width = newWidth

            // Shift text left so it stays centered in the initial bounds.
            val scrollX = (viewWidth - newWidth) / 2
            buttonText.scrollTo(scrollX, 0)
            buttonText.requestLayout()
        }
        doOnEnd {
            // Make the view invisible to seal its disappeared status. This could also be
            // "GONE" depending on the desired effect.
            buttonText.visibility = View.INVISIBLE
        }
        start()
    }
}

enter image description here

The technique is to lock in the size and height of the TextView to its initial size. From that initial size, the animation shrinks the view's width from the initial size to zero. Since the layout will position the start of the text to the start of the view, the text is scrolled left to maintain its position on the screen as the width shrinks.

The red line is there just to mark the center of the TextView and is not needed.

Causing the view to reappear is mostly the opposite of the code here.

The test layout:

<androidx.constraintlayout.widget.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_blue_dark"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/buttonText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_blue_light"
        android:text="Hello World!"
        android:textSize="48sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:layout_width="1dp"
        android:layout_height="0dp"
        android:background="@android:color/holo_red_light"
        app:layout_constraintBottom_toBottomOf="@id/buttonText"
        app:layout_constraintEnd_toEndOf="@id/buttonText"
        app:layout_constraintStart_toStartOf="@id/buttonText"
        app:layout_constraintTop_toTopOf="@id/buttonText" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Click Here"
        app:layout_constraintEnd_toEndOf="@+id/buttonText"
        app:layout_constraintStart_toStartOf="@id/buttonText"
        app:layout_constraintTop_toBottomOf="@id/buttonText" />
</androidx.constraintlayout.widget.ConstraintLayout>


There is another way to accomplish this which is to use a custom TextView that will clip its canvas to make the view shrink and expand. This method has the following advantages:

  • It is probably more efficient in that it does not require additional layout of the TextView.
  • If the TextView is within a ConstraintLayout and there are other views that are constrained to the start and/or end of the TextView, those views will not shift since the boundaries of the TextView remain unchanged.
private lateinit var buttonText: ClippedTextView 
fun fadeOutTextViewSize() {
    with(ValueAnimator.ofInt(buttonText.width, 0)) {
        duration = 1000
        interpolator = DecelerateInterpolator()

        addUpdateListener { animation ->
            val newWidth = animation.animatedValue as Int
            buttonText.setClippedWidth(newWidth)
            buttonText.invalidate()
        }
        start()
    }
}

ClippedTextView.kt

class ClippedTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : androidx.appcompat.widget.AppCompatTextView(context, attrs) {

    private var mClipWidth = 0

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        mClipWidth = right - left
    }

    override fun draw(canvas: Canvas) {
        val sideClipWidth = (width - mClipWidth) / 2
        canvas.withClip(sideClipWidth, 0, width - sideClipWidth, height) {
            super.draw(this)
        }
    }

    fun setClippedWidth(clipWidth: Int) {
        mClipWidth = clipWidth
    }
}


We can also bring all the logic into the custom TextView as follows:

ClippedTextView.kt

class ClippedTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : androidx.appcompat.widget.AppCompatTextView(context, attrs) {

    private var mClipWidth = 0
    private val mAnimator: ValueAnimator by lazy {
        ValueAnimator().apply {
            duration = 1000
            interpolator = DecelerateInterpolator()
            addUpdateListener { animation ->
                setClippedWidth(animation.animatedValue as Int)
                invalidate()
            }
        }
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        setClippedWidth(right - left)
    }

    override fun draw(canvas: Canvas) {
        val sideClipWidth = (width - mClipWidth) / 2
        canvas.withClip(sideClipWidth, 0, width - sideClipWidth, height) {
            super.draw(this)
        }
    }

    fun expandView() {
        doWidthAnimation(mClipWidth, 0)
    }

    fun shrinkView() {
        doWidthAnimation(mClipWidth, width)
    }

    private fun doWidthAnimation(startWidth: Int, endWidth: Int) {
        animation?.cancel()
        with(mAnimator) {
            setIntValues(startWidth, endWidth)
            start()
        }
    }

    private fun setClippedWidth(clipWidth: Int) {
        mClipWidth = clipWidth
    }
}
Related