Adetunji Dahunsi

Creating an Expandable Floating Action Button in Android

An implementation of an expanding FloatingActionButton in the Teammate Android App

TJ Dahunsi

Oct 16 2018 · 6 min read

Categories:

An implementation of an expanding FloatingActionButton in the Teammate Android App An implementation of an expanding FloatingActionButton in the Teammate Android App

Update: A more polished version of the FabExtensionAnimator is now available as a maven artifact using the androidx namespace, it doesn’t use a ConstraintLayout to animate the MaterialButton, but rather animates the MaterialButton itself with a spring. Read about it here: Creating an Expandable Floating Action Button in Android — Part 2 *Part 1 of this post is linked below:*medium.com

It can be found at the embedded resource below or by adding the following line to your build.gradle file provided you have a minSDK version of 21.

`implementation 'com.tunjid.androidx:material:1.0.0-rc06'`

tunjid/android-bootstrap An Android library with modules to quickly bootstrap an Android application. - tunjid/android-bootstrapgithub.com

Earlier this year, Google announced changes to Material Design in the form of Material Theming; Material Design is now more malleable, allowing it to better represent your brand, and look less sterile and cookie cutter.

Among the slew of changes that Material Theming brought, was the reimagining of the Floating Action Button as it originally debuted with Material Design; the Floating Action Button (henceforth referred to as a FAB) is now a little less cryptic, and a little more dynamic. It can house text to better describe itself, and when it doesn’t need to, can revert back to its more discrete, unassuming, yet prominent self.

This is really clever as it addresses one of the largest criticisms of the FAB; it was a little unintuitive and had a bit of mystery UI to it. Therefore, I was really excited to implement it in my app Teammate, and in this post I’m going to describe how.

Compose, don’t inherit

My favorite software engineering prose is favoring composition over inheritance, and this was an opportune moment to adhere to it. When creating a custom View in Android, it’s easy and tempting to extend a View class and add the custom behavior to it, however it may be better to create a utility class and add the desired attributes to it instead. The latest version of Material Components on Android provide both MaterialButton and FloatingActionButton components; but if you squint really closely, and throw in a healthy dose of padding, a MaterialButton looks quite a bit like an expanded FAB, so let’s mark this as our starting point.

An Extended FAB (1) along with 2 different styles of buttons (2 & 3). If button 2 rounded it’s corners some, and had just a little more padding… An Extended FAB (1) along with 2 different styles of buttons (2 & 3). If button 2 rounded it’s corners some, and had just a little more padding…

So our entire premise is to come up with a morphing animation that converts a MaterialButton to a FloatingActionButton seamlessly, with no jitters whatsoever. Whew, okay, we can do this.

My favorite Android ViewGroup of the past few years is easily the ConstraintLayout for a litany of reasons. However, it is pertinent in this case specifically for animations where child views move from one state to another, so it will be used as the foundation for our animation. We will define our two states in xml that describe the both the collapsed and expanded states of our expanding FAB:

Collapsed FAB:

1<?*xml version=*"1.0" *encoding=*"utf-8"?> 2<android.support.constraint.ConstraintLayout *android:id=*"@+id/extend_fab_container" 3 *xmlns:android=*"http://schemas.android.com/apk/res/android" 4 *xmlns:app=*"http://schemas.android.com/apk/res-auto" 5 *android:layout_width=*"wrap_content" 6 *android:layout_height=*"wrap_content" 7 *android:background=*"@drawable/bg_fab" 8 *android:elevation=*"8dp"> 9 10 <android.support.design.button.MaterialButton 11 *android:id=*"@+id/fab" 12 *style=*"@style/Widget.MaterialComponents.Button.UnelevatedButton" 13 *android:layout_width=*"56dp" 14 *android:layout_height=*"56dp" 15 *app:cornerRadius=*"56dp" 16 *app:layout_constraintBottom_toBottomOf=*"parent" 17 *app:layout_constraintLeft_toLeftOf=*"parent" 18 *app:layout_constraintRight_toRightOf=*"parent" 19 *app:layout_constraintTop_toTopOf=*"parent" /> 20</android.support.constraint.ConstraintLayout>

Expanded FAB:

1<?*xml version=*"1.0" *encoding=*"utf-8"?> 2<android.support.constraint.ConstraintLayout *android:id=*"@+id/extend_fab_container" 3 *xmlns:android=*"http://schemas.android.com/apk/res/android" 4 *xmlns:app=*"http://schemas.android.com/apk/res-auto" 5 *android:layout_width=*"wrap_content" 6 *android:layout_height=*"wrap_content" 7 *android:elevation=*"8dp" 8 *android:background=*"@drawable/bg_fab"> 9 10 <android.support.design.button.MaterialButton 11 *android:id=*"@+id/fab" 12 *style=*"@style/Widget.MaterialComponents.Button.UnelevatedButton" 13 *android:layout_width=*"wrap_content" 14 *android:layout_height=*"wrap_content" 15 *app:cornerRadius=*"56dp" 16 *app:layout_constraintBottom_toBottomOf=*"parent" 17 *app:layout_constraintLeft_toLeftOf=*"parent" 18 *app:layout_constraintRight_toRightOf=*"parent" 19 *app:layout_constraintTop_toTopOf=*"parent" /> 20</android.support.constraint.ConstraintLayout>

And of course, our background drawable, bg_fab:

1<?*xml version=*"1.0" *encoding=*"utf-8"?> 2<shape *xmlns:android=*"http://schemas.android.com/apk/res/android"> 3 <solid *android:color=*"@color/colorAccent" /> 4 <corners *android:radius=*"56dp" /> 5</shape>

With these states defined, we can now write the code that transitions the FAB from state to state:

1 private void setExtended(boolean extended, boolean force) { 2 if (isAnimating || (extended && isExtended() && !force)) return; 3 4 ConstraintSet set = new ConstraintSet(); 5 set.clone(container.getContext(), extended ? R.layout.fab_extended : R.layout.fab_collapsed); 6 7 TransitionManager.beginDelayedTransition(container, new AutoTransition() 8 .addListener(listener).setDuration(150)); 9 10 if (extended) button.setText(currentText); 11 else button.setText(""); 12 13 set.applyTo(container); 14 }

Simple, concise and sweet 😊.

You may have noticed that in the snippet above there are references made to the text and icon. This is because sometimes, we don’t just want to extend and collapse the FAB, but rather, we want to change the text, and we want that to animate as well, i.e the FAB should animate itself to accommodate any text changes we make to it.

Something that annoys me is that the ConstraintLayout exists only to animate the FAB. I tried removing it and just changing the LayoutParams of the MaterialButton directly, but it got wonky, so it’ll have to remain for now.

So far so good! However, our Extendable FAB is actually a composite ViewGroup that has no relationship to the original FAB in any way. This means if it were housed within a CoordinatorLayout, we’d lose the niceties that come for free with the default FAB such as the animations we get should a SnackBar appear.

This can easily be mitigated by creating (duplicating) the Behavior we desire from the original FAB class and attaching it to our composite component in xml.

1package com.mainstreetcode.teammate.util; 2 3import android.content.Context; 4import android.support.annotation.NonNull; 5import android.support.design.widget.CoordinatorLayout; 6import android.support.design.widget.Snackbar; 7import android.support.v4.view.ViewCompat; 8import android.support.v4.view.animation.FastOutSlowInInterpolator; 9import android.util.AttributeSet; 10import android.view.View; 11import android.view.animation.Interpolator; 12 13import java.util.List; 14 15/** 16 * Animates a {@link View} when a {@link Snackbar} appears. 17 * <p> 18 * Mostly identical to {@link android.support.design.widget.FloatingActionButton.Behavior} 19 * <p> 20 * Created by tj.dahunsi on 4/15/17. 21 */ 22@SuppressWarnings("unused") // Constructed via xml 23public class TransientBarBehavior extends CoordinatorLayout.Behavior<View> { 24 25 private static final Interpolator fastOutSlowInInterpolator = new FastOutSlowInInterpolator(); 26 27 public TransientBarBehavior(Context context, AttributeSet attrs) { 28 super(context, attrs); 29 } 30 31 @Override 32 public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { 33 return dependency instanceof Snackbar.SnackbarLayout; 34 } 35 36 @Override 37 public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { 38 if (child.getVisibility() == View.VISIBLE) { 39 float translationY = this.getViewTranslationYForSnackbar(parent, child); 40 child.setTranslationY(translationY); 41 } 42 return true; 43 } 44 45 @Override 46 public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { 47 if (dependency instanceof Snackbar.SnackbarLayout && child.getTranslationY() != 0.0F) { 48 ViewCompat.animate(child).translationY(0.0F).scaleX(1.0F).scaleY(1.0F).alpha(1.0F) 49 .setInterpolator(fastOutSlowInInterpolator); 50 } 51 } 52 53 private float getViewTranslationYForSnackbar(CoordinatorLayout parent, View child) { 54 float minOffset = 0.0F; 55 List dependencies = parent.getDependencies(child); 56 int i = 0; 57 58 for (int z = dependencies.size(); i < z; ++i) { 59 View view = (View) dependencies.get(i); 60 if (view instanceof Snackbar.SnackbarLayout && parent.doViewsOverlap(child, view)) { 61 minOffset = Math.min(minOffset, view.getTranslationY() - (float) view.getHeight()); 62 } 63 } 64 65 return minOffset; 66 } 67}

And viola! We now have an expandable FAB, by simply composing the views already provided to us with the Material Design Components for Android.

You can see the Expanding FAB in Action in the latest version of the Teammate Android App:

Teammate App on Google Play

The full source of the FabIconAnimator:

1package com.mainstreetcode.teammate.util; 2 3import android.animation.AnimatorSet; 4import android.animation.ObjectAnimator; 5import android.support.annotation.DrawableRes; 6import android.support.annotation.NonNull; 7import android.support.annotation.Nullable; 8import android.support.annotation.StringRes; 9import android.support.constraint.ConstraintLayout; 10import android.support.constraint.ConstraintSet; 11import android.support.design.button.MaterialButton; 12import android.transition.AutoTransition; 13import android.transition.Transition; 14import android.transition.TransitionManager; 15import android.view.View; 16 17import com.mainstreetcode.teammate.R; 18 19import java.util.concurrent.atomic.AtomicBoolean; 20 21public class FabIconAnimator { 22 23 private static final String ROTATION_Y_PROPERTY = "rotationY"; 24 25 private static final float TWITCH_END = 20F; 26 private static final float TWITCH_START = 0F; 27 private static final int DURATION = 200; 28 29 @DrawableRes private int currentIcon; 30 @StringRes private int currentText; 31 private boolean isAnimating; 32 33 private final MaterialButton button; 34 private final ConstraintLayout container; 35 private final Transition.TransitionListener listener = new Transition.TransitionListener() { 36 public void onTransitionStart(Transition transition) { isAnimating = true; } 37 38 public void onTransitionEnd(Transition transition) { isAnimating = false; } 39 40 public void onTransitionCancel(Transition transition) { isAnimating = false; } 41 42 public void onTransitionPause(Transition transition) { } 43 44 public void onTransitionResume(Transition transition) { } 45 }; 46 47 public FabIconAnimator(ConstraintLayout container) { 48 this.container = container; 49 this.button = container.findViewById(R.id.fab); 50 } 51 52 public void update(@DrawableRes int icon, @StringRes int text) { 53 boolean isSame = currentIcon == icon && currentText == text; 54 currentIcon = icon; 55 currentText = text; 56 animateChange(icon, text, isSame); 57 } 58 59 public void setExtended(boolean extended) { 60 setExtended(extended, false); 61 } 62 63 public void setOnClickListener(@Nullable View.OnClickListener clickListener) { 64 if (clickListener == null) { 65 button.setOnClickListener(null); 66 return; 67 } 68 AtomicBoolean flag = new AtomicBoolean(true); 69 button.setOnClickListener(view -> { 70 if (!flag.getAndSet(false)) return; 71 clickListener.onClick(view); 72 button.postDelayed(() -> flag.set(true), 2000); 73 }); 74 } 75 76 private boolean isExtended() { // R.dimen.triple_and_half_margin is 56 dp. 77 return button.getLayoutParams().height != button.getResources().getDimensionPixelSize(R.dimen.triple_and_half_margin); 78 } 79 80 private void animateChange(@DrawableRes int icon, @StringRes int text, boolean isSame) { 81 boolean extended = isExtended(); 82 button.setText(text); 83 button.setIconResource(icon); 84 setExtended(extended, !isSame); 85 if (!extended) twitch(); 86 } 87 88 private void setExtended(boolean extended, boolean force) { 89 if (isAnimating || (extended && isExtended() && !force)) return; 90 91 ConstraintSet set = new ConstraintSet(); 92 set.clone(container.getContext(), extended ? R.layout.fab_extended : R.layout.fab_collapsed); 93 94 TransitionManager.beginDelayedTransition(container, new AutoTransition() 95 .addListener(listener).setDuration(150)); 96 97 if (extended) button.setText(currentText); 98 else button.setText(""); 99 100 set.applyTo(container); 101 } 102 103 private void twitch() { 104 AnimatorSet set = new AnimatorSet(); 105 ObjectAnimator twitchA = animateProperty(ROTATION_Y_PROPERTY, TWITCH_START, TWITCH_END); 106 ObjectAnimator twitchB = animateProperty(ROTATION_Y_PROPERTY, TWITCH_END, TWITCH_START); 107 108 set.play(twitchB).after(twitchA); 109 set.start(); 110 } 111 112 @NonNull 113 private ObjectAnimator animateProperty(String property, float start, float end) { 114 return ObjectAnimator.ofFloat(container, property, start, end).setDuration(DURATION); 115 } 116}

0