Android Tutorial: Creating Buttons that Appear Conditionally on Scroll


Android Tutorial for showing a button conditionally on scroll

Imagine you’re scrolling down a very long screen (this is a common situation with Privacy Policies). As you scroll down the ‘Accept’ button scrolls off the screen. This is a short but helpful Android Tutorial for fixing this UX problem.

I will show you how to place the button in an alternative position: the bottom of the screen. When the original button reappears, the button at the bottom should disappear (which means that you should never have both buttons visible on the screen).

It shoud look like this:

Android Tutorial for showing a button conditionally on scroll

The support library provides us with two components that are very useful for this Android tutorial:

– The button at the bottom of the screen can be implemented with the help of a Bottom Sheet component.

– For identifying the scrolling behavior and triggering the appearance of the Bottom Sheet, we can use a Coordinator Layout.

Android Authority has published two quite helpful tutorials, with source code on Github for these two components:

– Bottom Sheets

– Coordinator Layout

So, the first step is quite straightforward: add the button at the bottom as a Bottom Sheet.

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <ScrollView
        android:id="@+id/scroll_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:id="@+id/c"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_horizontal"
            android:orientation="vertical"
            android:paddingBottom="70dp"
            android:paddingLeft="32dp"
            android:paddingRight="32dp"
            android:paddingTop="20dp">

            <TextView
                android:layout_marginTop="16dp"
                android:text="@string/privacy_header_1"
                style="@style/header"/>

            <TextView
                style="@style/privacy_text"
                android:layout_marginTop="29dp"
                android:text="@string/privacy_text"/>

            <Button
                android:id="@+id/got_it"
                android:layout_width="160dp"
                android:layout_height="50dp"
                android:layout_marginTop="29dp"
                android:text="@string/understood"
                android:background="@drawable/rounded_button"
                android:textAppearance="@style/rounded_button_text"/>


            <TextView
                style="@style/header"
                android:layout_marginTop="48dp"
                android:text="@string/privacy_header_2"/>


            <TextView
                android:id="@+id/long_long_text"
                style="@style/privacy_text"
                android:layout_marginTop="23dp"
                android:text="@string/long_text"/>

        </LinearLayout>
    </ScrollView>

    <FrameLayout
        android:id="@+id/bottom_sheet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:behavior_hideable="true"
        app:layout_behavior="android.support.design.widget.BottomSheetBehavior">

        <Button
            android:id="@+id/bottom_button"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:background="@color/colorPrimary"
            android:text="@string/understood"
            android:textAppearance="@style/rounded_button_text"/>
    </FrameLayout>

</android.support.design.widget.CoordinatorLayout>

The layout consists of a CoordinatorLayout that has two children: a ScrollView with the scrollable content and a FrameLayout with the bottom button. We can now manually show/hide the bottom sheet by calling

bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED)

or

bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN).

But of course we don’t want to bring the Bottom Sheet up manually, we want it to appear automatically when the rounded button disappears off the screen. The second tutorial of Android Authority that we have linked above demonstrates in case 4, how we can implement a custom behavior and alter a view’s attributes when another widget goes off the screen.
So we could implement a custom CoordinatorLayout.Behavior following the tutorial, and, instead of toggling the button’s child, set the Bottom sheet’s state to expanded.

But, wait… then we would have to add this custom CoordinatorLayout.Behavior to the bottom_sheet FrameLayout, but this already has a coordinator layout set. And we can’t add two behaviors.

The solution is simple. We just need to extend the BottomSheetBehavior as in the snippet below: (we don’t even have to resort to a Decorator — simple inheritance works).

package app.we.go.bottombutton;

import android.content.Context;
import android.support.design.widget.BottomSheetBehavior;
import android.support.design.widget.CoordinatorLayout;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.FrameLayout;


/**
 * {@link BottomSheetBehavior} that shows automatically when the dependency goes out of the screen
 * and hides when it comes back in.
 */
public class OutOfScreenBottomSheetBehavior extends BottomSheetBehavior<FrameLayout> {


    private int statusBarHeight;

    public OutOfScreenBottomSheetBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);

        int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            statusBarHeight = context.getResources().getDimensionPixelSize(resourceId);
        }
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, FrameLayout child, View dependency) {
        return dependency.getId() == R.id.behavior_dependency;
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, FrameLayout child, View dependency) {
        int[] dependencyLocation = new int[2];

        dependency.getLocationInWindow(dependencyLocation);
        Log.d("BEHAVIOR", "Location: " + dependencyLocation[1]);

        if (dependencyLocation[1] <= statusBarHeight) {
            if (getState() != STATE_EXPANDED) {
                setState(STATE_EXPANDED);
            }
        } else {
            setState(STATE_HIDDEN);
        }
        return false;
    }

}

The implementation is really simple, we just get the position of the coordinator’s dependency, and as soon as it hides behind the status bar, we set the state to STATE_EXPANDED by calling the parent’s setState method.

A few notes about the code above:

  • The code uses a somewhat unsafe method of calculating the status bar’s height. There are countless questions on StackOverflow about how to get the status bar’s height. I consider this to be the cleaner one. If you want to avoid it, you might be tempted to just trigger the action when the y-location of the dependency on screen is 0. Keep in mind though that if your status bar is not translucent, the position will never be 0.
  • For all this to work, there is a small addition that we need to do to the layout code in order for the whole thing to work.
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!--  The Scroll View as before, but we are ommiting it... -->

    <FrameLayout
        android:id="@+id/bottom_sheet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:behavior_hideable="true"
        app:layout_behavior="app.we.go.bottombutton.OutOfScreenBottomSheetBehavior">

        <Button
            android:id="@+id/bottom_button"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:background="@color/colorPrimary"
            android:text="@string/understood"
            android:textAppearance="@style/rounded_button_text"/>
    </FrameLayout>


    <!-- This view serves as the dependency of the of the OutOfScreenBottomSheetBehavior
     (it has to be a direct child of the CoordinatorLayout) -->
    <View
        android:id="@+id/behavior_dependency"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_anchor="@id/got_it"
        app:layout_anchorGravity="bottom"/>

</android.support.design.widget.CoordinatorLayout>

The CoordinatorLayout only “coordinates” its direct children, so we can’t actually make the behavior directly depend on the rounded_button. The trick to overcome this limitation is to add the dummy View behavior_dependency, which is anchored to the rounded_button and so moves in sync with it, and then use this view as the dependency view in our Custom behavior.

Check out the full source code from this Android tutorial on Github.