Button / StateListAnimator

Dude, where’s my Button?

I recently encountered a rather strange problem when tidying up an existing layout whereby a button started behaving oddly once I had cleaned up the layout. Once I understood what was happening it was pretty easy to get it fixed up, but the initial behaviour was somewhat baffling at first.

Before we dive in to the problem itself, I’ll provide a little background. I was reviewing an existing layout in a project that I’ve just started working on. The layout in question was a ConstraintLayout which had a couple of LinearLayouts embedded within it. This immediately caused me some consternation as there was absolutely no reason that I could see for doing this, and I felt that we could flatten the layout so that all of the Views within the layout hierarchy could be direct children of a single ConstraintLayout. When I had a short period where I had no other urgent tasks, I set about cleaning things up, but hit this issue.

Let’s begin by taking a look at a very simplified form of the layout I was working on. It had a scrollable area, and a panel containing a button at the bottom:

<?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">

  <ScrollView
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@id/button_panel"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="0.0">

    <TextView
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="16dp"
      android:text="@string/lorem" />
  </ScrollView>

  <FrameLayout
    android:id="@+id/button_panel"
    android:layout_width="0dp"
    android:layout_height="@dimen/button_panel_height"
    android:elevation="24dp"
    android:background="@android:color/white"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent">

    <Button
     style="@style/ButtonStyle"
     android:id="@+id/button"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:layout_margin="16dp"
      android:text="@string/button_text"/>
  </FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

I have actually replaced the LinearLayout with a FrameLayout for this example, but this demonstrates how the Button is the only child of the FrameLayout, which is a child of the ConstraintLayout. It looks like this:

On the face of it, refactoring this to be a single tier seemed easy enough, replace the FrameLayout with a standard View to provide the background panel, and move the Button to the top level:

<?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">

  <ScrollView
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@id/button_panel"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="0.0">

    <TextView
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="16dp"
      android:text="@string/lorem" />
  </ScrollView>

  <View
    android:id="@+id/button_panel"
    android:layout_width="0dp"
    android:layout_height="@dimen/button_panel_height"
    android:background="@android:color/white"
    android:elevation="24dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent" />

  <Button
    android:id="@+id/button"
    style="@style/ButtonStyle"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_margin="16dp"
    android:elevation="24dp"
    android:text="@string/button_text"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="@id/button_panel"/>

</androidx.constraintlayout.widget.ConstraintLayout>

That looks like it should work perfectly, but if we run if we run it:

The Button has disappeared.

The problem is all down to the elevation that we are using on both the Button and the View. This is needed because the design called for a shadow on the top edge of the background panel, and this was achieved using an elevation of 24dp and the Android Framework shadow renderer takes care of the rest. That same elevation is being applied to the Button so it should appear on top of the background panel, but actually that is not the case. To understand why, let’s remove the elevation from both of these for a moment, and we get the following behaviour:

The important thing worth noting here is the Button itself. It has a shadow despite me setting the elevation to 0dp in the layout. The reason for this becomes apparent from the GIF: When the button is pressed, it rises to meet the user’s finger, and that is a behaviour which is a default for Buttons from API 21 (Lollipop) onwards. It is this inherent elevation behaviour which is causing the Button to disappear when we raise the elevation of the background panel because, despite manually setting the elevation of the button as well, the intrinsic behaviour takes over and lowers it down behind the background panel, so it disappears.

I am indebted to Sebastiano Poggi for not only bouncing ideas around with, but for suggesting a really quick and easy fix for this: Set the StateListAnimator of the Button to null. We set this and re-apply the elevation:

<?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">

  <ScrollView
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@id/button_panel"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="0.0">

    <TextView
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="16dp"
      android:text="@string/lorem" />
  </ScrollView>

  <View
    android:id="@+id/button_panel"
    android:layout_width="0dp"
    android:layout_height="@dimen/button_panel_height"
    android:background="@android:color/white"
    android:elevation="24dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent" />

  <Button
    android:id="@+id/button"
    style="@style/ButtonStyle"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_margin="16dp"
    android:elevation="24dp"
    android:stateListAnimator="@null"
    android:text="@string/button_text"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="@id/button_panel" />

</androidx.constraintlayout.widget.ConstraintLayout>

If we do this then the button re-appears:

To understand why this works, it is worth taking a look at the the material button StateListAnimator from AOSP:

<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project

     Licensed under the Apache License, Version 2.0 (the "License");
     you may not use this file except in compliance with the License.
     You may obtain a copy of the License at

          http://www.apache.org/licenses/LICENSE-2.0

     Unless required by applicable law or agreed to in writing, software
     distributed under the License is distributed on an "AS IS" BASIS,
     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     See the License for the specific language governing permissions and
     limitations under the License.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true" android:state_enabled="true">
        <set>
            <objectAnimator android:propertyName="translationZ"
                            android:duration="@integer/button_pressed_animation_duration"
                            android:valueTo="@dimen/button_pressed_z_material"
                            android:valueType="floatType"/>
            <objectAnimator android:propertyName="elevation"
                            android:duration="0"
                            android:valueTo="@dimen/button_elevation_material"
                            android:valueType="floatType"/>
        </set>
    </item>
    <!-- base state -->
    <item android:state_enabled="true">
        <set>
            <objectAnimator android:propertyName="translationZ"
                            android:duration="@integer/button_pressed_animation_duration"
                            android:valueTo="0"
                            android:startDelay="@integer/button_pressed_animation_delay"
                            android:valueType="floatType"/>
            <objectAnimator android:propertyName="elevation"
                            android:duration="0"
                            android:valueTo="@dimen/button_elevation_material"
                            android:valueType="floatType" />
        </set>
    </item>
    <item>
        <set>
            <objectAnimator android:propertyName="translationZ"
                            android:duration="0"
                            android:valueTo="0"
                            android:valueType="floatType"/>
            <objectAnimator android:propertyName="elevation"
                            android:duration="0"
                            android:valueTo="0"
                            android:valueType="floatType"/>
        </set>
    </item>
</selector>

There are three states here:

  • When the Button is both enabled and pressed.
  • When the button is enabled.
  • All other states

These will be matched in turn and when a match is found its animator will be run, and no further matching is done. There are a couple of dimens values in here which are worth looking at:

<dimen name="button_elevation_material">2dp</dimen>
<dimen name="button_pressed_z_material">4dp</dimen>

When our layout is inflated, the elevation from the layout is applied, but then the enabled button state is applied which sets the elevation to @dimen/button_elevation_material which is 2dp. That’s why the button disappears when we use the default StateListAnimator.

It’s important to note that we no longer have the default behaviour of the Button rising to meet the user’s finger but that was lost as a result of this change. For this particular use-case that was actually what we wanted, so it was mission accomplished. We now have a much flatter ConstraintLayout which will inflate, measure, and layout much more efficiently, and also be easier to perform layout animations on. But also, the Button now has the desired visibility and behaviour.

The source code for this article is available here.

© 2018, Mark Allison. All rights reserved.

Copyright © 2018 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.