/* * Copyright (C) 2019 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. */ package com.google.android.material.floatingactionbutton; import com.google.android.material.R; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import static java.lang.Math.min; import android.animation.Animator; import android.animation.Animator.AnimatorListener; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.PropertyValuesHolder; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Rect; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Property; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewGroup.MarginLayoutParams; import androidx.annotation.AnimatorRes; import androidx.annotation.ColorInt; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout.AttachedBehavior; import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior; import com.google.android.material.animation.MotionSpec; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.button.MaterialButton; import com.google.android.material.internal.DescendantOffsetUtils; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.shape.ShapeAppearanceModel; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.List; /** * Extended floating action buttons are used for a special type of promoted action. They are * distinguished by an icon and a text floating above the UI and have special motion behaviors * related to morphing, launching, and the transferring anchor point. * *
Extended floating action buttons may have icon and text, but may also hold just an icon or * text. * *
As this class descends from {@link MaterialButton}, you can control the icon which is * displayed via {@link #setIcon(android.graphics.drawable.Drawable)}, and the text via {@link * #setText(CharSequence)}. * *
The background color of this view defaults to the your theme's {@code colorSecondary}. If you * wish to change this at runtime then you can do so via {@link * #setBackgroundTintList(android.content.res.ColorStateList)}. * *
For more information, see the component
* developer guidance and design
* guidelines.
*/
public class ExtendedFloatingActionButton extends MaterialButton implements AttachedBehavior {
private static final int DEF_STYLE_RES =
R.style.Widget_MaterialComponents_ExtendedFloatingActionButton_Icon;
private static final int ANIM_STATE_NONE = 0;
private static final int ANIM_STATE_HIDING = 1;
private static final int ANIM_STATE_SHOWING = 2;
private int animState = ANIM_STATE_NONE;
/** Strategy to show the FAB. */
private static final int SHOW = 0;
/** Strategy to hide the FAB. */
private static final int HIDE = 1;
/** Strategy to shrink the FAB. */
private static final int SHRINK = 2;
/** Strategy to extend the FAB. */
private static final int EXTEND = 3;
private boolean animationEnabled = true;
/**
* The strategy type determines what motion strategy to apply on the FAB.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@IntDef({SHOW, HIDE, SHRINK, EXTEND})
@Retention(RetentionPolicy.SOURCE)
private @interface StrategyType {}
private final AnimatorTracker changeVisibilityTracker = new AnimatorTracker();
@NonNull private final MotionStrategy shrinkStrategy;
@NonNull private final MotionStrategy extendStrategy;
private final MotionStrategy showStrategy = new ShowStrategy(changeVisibilityTracker);
private final MotionStrategy hideStrategy = new HideStrategy(changeVisibilityTracker);
private int collapsedSize;
private int extendedPaddingStart;
private int extendedPaddingEnd;
@NonNull private final Behavior This may be set to {@code true} if the button is initially hidden but should animate when
* later shown. The default is {@code false}.
*/
public void setAnimateShowBeforeLayout(boolean animateShowBeforeLayout) {
this.animateShowBeforeLayout = animateShowBeforeLayout;
}
@Override
public void setPaddingRelative(int start, int top, int end, int bottom) {
super.setPaddingRelative(start, top, end, bottom);
if (isExtended && !isTransforming) {
extendedPaddingStart = start;
extendedPaddingEnd = end;
}
}
@Override
public void setPadding(int left, int top, int right, int bottom) {
super.setPadding(left, top, right, bottom);
if (isExtended && !isTransforming) {
extendedPaddingStart = getPaddingStart();
extendedPaddingEnd = getPaddingEnd();
}
}
@Override
public CharSequence getAccessibilityClassName() {
return FloatingActionButton.ACCESSIBIILTY_FAB_ROLE;
}
/**
* Add a listener that will be invoked when this ExtendedFloatingActionButton is shown. See {@link
* AnimatorListener}.
*
* Components that add a listener should take care to remove it when finished via {@link
* #removeOnShowAnimationListener(AnimatorListener)}.
*
* @param listener listener to add
*/
public void addOnShowAnimationListener(@NonNull AnimatorListener listener) {
showStrategy.addAnimationListener(listener);
}
/**
* Remove a listener that was previously added via
* {@link #addOnShowAnimationListener(AnimatorListener)}.
*
* @param listener listener to remove
*/
public void removeOnShowAnimationListener(@NonNull AnimatorListener listener) {
showStrategy.removeAnimationListener(listener);
}
/**
* Add a listener that will be invoked when this ExtendedFloatingActionButton is hidden. See
* {@link AnimatorListener}.
*
* Components that add a listener should take care to remove it when finished via {@link
* #removeOnHideAnimationListener(AnimatorListener)}.
*
* @param listener listener to add
*/
public void addOnHideAnimationListener(@NonNull AnimatorListener listener) {
hideStrategy.addAnimationListener(listener);
}
/**
* Remove a listener that was previously added via
* {@link #addOnHideAnimationListener(AnimatorListener)}.
*
* @param listener listener to remove
*/
public void removeOnHideAnimationListener(@NonNull AnimatorListener listener) {
hideStrategy.removeAnimationListener(listener);
}
/**
* Add a listener that will be invoked when this ExtendedFloatingActionButton is shrunk. See
* {@link AnimatorListener}.
*
* Components that add a listener should take care to remove it when finished via {@link
* #removeOnShrinkAnimationListener(AnimatorListener)}.
*
* @param listener listener to add
*/
public void addOnShrinkAnimationListener(@NonNull AnimatorListener listener) {
shrinkStrategy.addAnimationListener(listener);
}
/**
* Remove a listener that was previously added via
* {@link #addOnShrinkAnimationListener(AnimatorListener)}.
*
* @param listener listener to remove
*/
public void removeOnShrinkAnimationListener(@NonNull AnimatorListener listener) {
shrinkStrategy.removeAnimationListener(listener);
}
/**
* Add a listener that will be invoked when this ExtendedFloatingActionButton is extended. See
* {@link AnimatorListener}.
*
* Components that add a listener should take care to remove it when finished via {@link
* #removeOnExtendAnimationListener(AnimatorListener)}.
*
* @param listener listener to add
*/
public void addOnExtendAnimationListener(@NonNull AnimatorListener listener) {
extendStrategy.addAnimationListener(listener);
}
/**
* Remove a listener that was previously added via
* {@link #addOnExtendAnimationListener(AnimatorListener)}.
*
* @param listener listener to remove
*/
public void removeOnExtendAnimationListener(@NonNull AnimatorListener listener) {
extendStrategy.removeAnimationListener(listener);
}
/**
* Hides the button.
*
* This method will animate the button hide if the view has already been laid out.
*/
public void hide() {
performMotion(HIDE, null);
}
/**
* Hides the button.
*
* This method will animate the button hide if the view has already been laid out.
*
* @param callback the callback to notify when this view is hidden
*/
public void hide(@NonNull OnChangedCallback callback) {
performMotion(HIDE, callback);
}
/**
* Shows the button.
*
* This method will animate the button show if the view has already been laid out, or if {@link
* #setAnimateShowBeforeLayout} is {@code true}.
*/
public void show() {
performMotion(SHOW, null);
}
/**
* Shows the button.
*
* This method will animate the button show if the view has already been laid out, or if {@link
* #setAnimateShowBeforeLayout} is {@code true}.
*
* @param callback the callback to notify when this view is shown
*/
public void show(@NonNull OnChangedCallback callback) {
performMotion(SHOW, callback);
}
/**
* Extends the FAB to show the text and the icon.
*
* This method will not affect an extended FAB which holds just text and no icon. Also, this
* method will animate the button show if the view has already been laid out.
*
* @see #extend(OnChangedCallback)
*/
public void extend() {
performMotion(EXTEND, null);
}
/**
* Extends the FAB to show the text and the icon.
*
* This method will not affect an extended FAB which holds just text and no icon. Also, this
* method will animate the button show if the view has already been laid out.
*
* @param callback the callback to notify when the FAB is extended
*/
public void extend(@NonNull final OnChangedCallback callback) {
performMotion(EXTEND, callback);
}
/**
* Shrinks the FAB to show just the icon.
*
* This method will not affect an extended FAB which holds just text and no icon. Also, this
* method will animate the button show if the view has already been laid out.
*
* @see #shrink(OnChangedCallback)
*/
public void shrink() {
performMotion(SHRINK, null);
}
/**
* Shrinks the FAB to show just the icon.
*
* This method will not affect an extended FAB which holds just text and no icon. Also, this
* method will animate the button show if the view has already been laid out.
*
* @param callback the callback to notify when the FAB shrank
*/
public void shrink(@NonNull final OnChangedCallback callback) {
performMotion(SHRINK, callback);
}
/** Returns the motion spec for the show animation. */
@Nullable
public MotionSpec getShowMotionSpec() {
return showStrategy.getMotionSpec();
}
/**
* Updates the motion spec for the show animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_showMotionSpec
*/
public void setShowMotionSpec(@Nullable MotionSpec spec) {
showStrategy.setMotionSpec(spec);
}
/**
* Updates the motion spec for the show animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_showMotionSpec
*/
public void setShowMotionSpecResource(@AnimatorRes int id) {
setShowMotionSpec(MotionSpec.createFromResource(getContext(), id));
}
/** Returns the motion spec for the hide animation. */
@Nullable
public MotionSpec getHideMotionSpec() {
return hideStrategy.getMotionSpec();
}
/**
* Updates the motion spec for the hide animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_hideMotionSpec
*/
public void setHideMotionSpec(@Nullable MotionSpec spec) {
hideStrategy.setMotionSpec(spec);
}
/**
* Updates the motion spec for the hide animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_hideMotionSpec
*/
public void setHideMotionSpecResource(@AnimatorRes int id) {
setHideMotionSpec(MotionSpec.createFromResource(getContext(), id));
}
/** Returns the motion spec for the extend animation. */
@Nullable
public MotionSpec getExtendMotionSpec() {
return extendStrategy.getMotionSpec();
}
/**
* Updates the motion spec for the extend animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_extendMotionSpec
*/
public void setExtendMotionSpec(@Nullable MotionSpec spec) {
extendStrategy.setMotionSpec(spec);
}
/**
* Updates the motion spec for the extend animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_extendMotionSpec
*/
public void setExtendMotionSpecResource(@AnimatorRes int id) {
setExtendMotionSpec(MotionSpec.createFromResource(getContext(), id));
}
/**
* Returns the motion spec for the shrink animation.
*/
@Nullable
public MotionSpec getShrinkMotionSpec() {
return shrinkStrategy.getMotionSpec();
}
/**
* Updates the motion spec for the shrink animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_shrinkMotionSpec
*/
public void setShrinkMotionSpec(@Nullable MotionSpec spec) {
shrinkStrategy.setMotionSpec(spec);
}
/**
* Updates the motion spec for the shrink animation.
*
* @attr ref com.google.android.material.R.styleable#ExtendedFloatingActionButton_shrinkMotionSpec
*/
public void setShrinkMotionSpecResource(@AnimatorRes int id) {
setShrinkMotionSpec(MotionSpec.createFromResource(getContext(), id));
}
private void performMotion(
@StrategyType final int strategyType, @Nullable final OnChangedCallback callback) {
MotionStrategy strategy;
switch (strategyType) {
case SHOW:
strategy = showStrategy;
break;
case HIDE:
strategy = hideStrategy;
break;
case SHRINK:
strategy = shrinkStrategy;
break;
case EXTEND:
strategy = extendStrategy;
break;
default:
throw new IllegalStateException("Unknown strategy type: " + strategyType);
}
if (strategy.shouldCancel()) {
return;
}
boolean shouldAnimate = shouldAnimateVisibilityChange();
if (!shouldAnimate) {
strategy.performNow();
strategy.onChange(callback);
return;
}
// If shrinking, we should remember what width to re-expand at.
if (strategyType == SHRINK) {
LayoutParams lp = getLayoutParams();
if (lp != null) {
originalWidth = lp.width;
originalHeight = lp.height;
} else {
originalWidth = getWidth();
originalHeight = getHeight();
}
}
measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
Animator animator = strategy.createAnimator();
animator.addListener(
new AnimatorListenerAdapter() {
private boolean cancelled;
@Override
public void onAnimationStart(Animator animation) {
strategy.onAnimationStart(animation);
cancelled = false;
}
@Override
public void onAnimationCancel(Animator animation) {
cancelled = true;
strategy.onAnimationCancel();
}
@Override
public void onAnimationEnd(Animator animation) {
strategy.onAnimationEnd();
if (!cancelled) {
strategy.onChange(callback);
}
}
});
for (AnimatorListener l : strategy.getListeners()) {
animator.addListener(l);
}
animator.start();
}
private boolean isOrWillBeShown() {
if (getVisibility() != View.VISIBLE) {
// If we're not currently visible, return true if we're animating to be shown
return animState == ANIM_STATE_SHOWING;
} else {
// Otherwise if we're visible, return true if we're not animating to be hidden
return animState != ANIM_STATE_HIDING;
}
}
private boolean isOrWillBeHidden() {
if (getVisibility() == View.VISIBLE) {
// If we're currently visible, return true if we're animating to be hidden
return animState == ANIM_STATE_HIDING;
} else {
// Otherwise if we're not visible, return true if we're not animating to be shown
return animState != ANIM_STATE_SHOWING;
}
}
/**
* Set whether or not animations are enabled.
*/
public void setAnimationEnabled(boolean animationEnabled) {
this.animationEnabled = animationEnabled;
}
/** Return whether or not animations are enabled. */
public boolean isAnimationEnabled() {
return animationEnabled;
}
private boolean shouldAnimateVisibilityChange() {
return animationEnabled
&& (isLaidOut() || (!isOrWillBeShown() && animateShowBeforeLayout))
&& !isInEditMode();
}
/**
* A Property wrapper around the In case auto-shrink is enabled, it will take precedence over the auto-hide option.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoHide
* @param autoHide true to enable automatic hiding
*/
public void setAutoHideEnabled(boolean autoHide) {
autoHideEnabled = autoHide;
}
/**
* Returns whether the associated ExtendedFloatingActionButton automatically hides when there is
* not enough space to be displayed.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoHide
* @return true if enabled
*/
public boolean isAutoHideEnabled() {
return autoHideEnabled;
}
/**
* Sets whether the associated ExtendedFloatingActionButton automatically shrink when there is
* not enough space to be displayed. This works with {@link AppBarLayout} and {@link
* BottomSheetBehavior}.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoShrink
* @param autoShrink true to enable automatic shrinking
*/
public void setAutoShrinkEnabled(boolean autoShrink) {
autoShrinkEnabled = autoShrink;
}
/**
* Returns whether the associated ExtendedFloatingActionButton automatically shrinks when there
* is not enough space to be displayed.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoShrink
* @return true if enabled
*/
public boolean isAutoShrinkEnabled() {
return autoShrinkEnabled;
}
@Override
@SuppressWarnings("unchecked")
// TODO: remove this stub method. Adding now to maintain compatibility since the override is
// not correct.
public boolean getInsetDodgeRect(
@NonNull CoordinatorLayout parent,
@NonNull ExtendedFloatingActionButton child,
@NonNull Rect rect) {
return super.getInsetDodgeRect(parent, (T) child, rect);
}
@Override
public void onAttachedToLayoutParams(@NonNull CoordinatorLayout.LayoutParams lp) {
if (lp.dodgeInsetEdges == Gravity.NO_GRAVITY) {
// If the developer hasn't set dodgeInsetEdges, lets set it to BOTTOM so that
// we dodge any Snackbars
lp.dodgeInsetEdges = Gravity.BOTTOM;
}
}
@Override
public boolean onDependentViewChanged(
CoordinatorLayout parent, @NonNull ExtendedFloatingActionButton child, View dependency) {
if (dependency instanceof AppBarLayout) {
// If we're depending on an AppBarLayout we will show/hide it automatically
// if the FAB is anchored to the AppBarLayout
updateFabVisibilityForAppBarLayout(parent, (AppBarLayout) dependency, child);
} else if (isBottomSheet(dependency)) {
updateFabVisibilityForBottomSheet(dependency, child);
}
return false;
}
private static boolean isBottomSheet(@NonNull View view) {
final ViewGroup.LayoutParams lp = view.getLayoutParams();
if (lp instanceof CoordinatorLayout.LayoutParams) {
return ((CoordinatorLayout.LayoutParams) lp).getBehavior() instanceof BottomSheetBehavior;
}
return false;
}
@VisibleForTesting
void setInternalAutoHideCallback(@Nullable OnChangedCallback callback) {
internalAutoHideCallback = callback;
}
@VisibleForTesting
void setInternalAutoShrinkCallback(@Nullable OnChangedCallback callback) {
internalAutoShrinkCallback = callback;
}
// dereference of possibly-null reference lp
@SuppressWarnings("nullness:dereference.of.nullable")
private boolean shouldUpdateVisibility(
@NonNull View dependency, @NonNull ExtendedFloatingActionButton child) {
final CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) child.getLayoutParams();
if (!autoHideEnabled && !autoShrinkEnabled) {
return false;
}
if (lp.getAnchorId() != dependency.getId()) {
// The anchor ID doesn't match the dependency, so we won't automatically
// show/hide the FAB
return false;
}
return true;
}
private boolean updateFabVisibilityForAppBarLayout(
CoordinatorLayout parent,
@NonNull AppBarLayout appBarLayout,
@NonNull ExtendedFloatingActionButton child) {
if (!shouldUpdateVisibility(appBarLayout, child)) {
return false;
}
if (tmpRect == null) {
tmpRect = new Rect();
}
// First, let's get the visible rect of the dependency
final Rect rect = tmpRect;
DescendantOffsetUtils.getDescendantRect(parent, appBarLayout, rect);
if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
// If the anchor's bottom is below the seam, we'll animate our FAB out
shrinkOrHide(child);
} else {
// Else, we'll animate our FAB back in
extendOrShow(child);
}
return true;
}
// dereference of possibly-null reference lp
@SuppressWarnings("nullness:dereference.of.nullable")
private boolean updateFabVisibilityForBottomSheet(
@NonNull View bottomSheet, @NonNull ExtendedFloatingActionButton child) {
if (!shouldUpdateVisibility(bottomSheet, child)) {
return false;
}
CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
if (bottomSheet.getTop() < child.getHeight() / 2 + lp.topMargin) {
shrinkOrHide(child);
} else {
extendOrShow(child);
}
return true;
}
/**
* Shrinks the Extended FAB, in case auto-shrink is enabled, or hides it in case auto-hide is
* enabled. The priority is given to the default shrink option, and the button will be hidden
* only when the auto-shrink is {@code false} and auto-hide is {@code true}.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoShrink
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoHide
* @see #setAutoShrinkEnabled(boolean)
* @see #setAutoHideEnabled(boolean)
*/
protected void shrinkOrHide(@NonNull ExtendedFloatingActionButton fab) {
OnChangedCallback callback = autoShrinkEnabled
? internalAutoShrinkCallback
: internalAutoHideCallback;
fab.performMotion(autoShrinkEnabled ? SHRINK : HIDE, callback);
}
/**
* Extends the Extended FAB, in case auto-shrink is enabled, or show it in case auto-hide is
* enabled. The priority is given to the default extend option, and the button will be shown
* only when the auto-shrink is {@code false} and auto-hide is {@code true}.
*
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoShrink
* @attr ref
* com.google.android.material.R.styleable#ExtendedFloatingActionButton_Behavior_Layout_behavior_autoHide
* @see #setAutoShrinkEnabled(boolean)
* @see #setAutoHideEnabled(boolean)
*/
protected void extendOrShow(@NonNull ExtendedFloatingActionButton fab) {
OnChangedCallback callback = autoShrinkEnabled
? internalAutoShrinkCallback
: internalAutoHideCallback;
fab.performMotion(autoShrinkEnabled ? EXTEND : SHOW, callback);
}
@Override
public boolean onLayoutChild(
@NonNull CoordinatorLayout parent,
@NonNull ExtendedFloatingActionButton child,
int layoutDirection) {
// First, let's make sure that the visibility of the FAB is consistent
final Listwidth functionality handled by the {@link
* LayoutParams#width} value.
*/
static final Propertyheight functionality handled by the {@link
* LayoutParams#height} value.
*/
static final PropertypaddingStart functionality handled by the {@link
* View#setPaddingRelative(int, int, int, int)}.
*/
static final PropertypaddingEnd functionality handled by the {@link
* View#setPaddingRelative(int, int, int, int)}.
*/
static final Property