FloatingActionButton.java revision a419ee1ef9aef8b567f1ccd8c29d01ec7bff4cc9
1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.design.widget;
18
19import android.annotation.TargetApi;
20import android.content.Context;
21import android.content.res.ColorStateList;
22import android.content.res.TypedArray;
23import android.graphics.PorterDuff;
24import android.graphics.Rect;
25import android.graphics.drawable.Drawable;
26import android.os.Build;
27import android.support.annotation.ColorInt;
28import android.support.annotation.Nullable;
29import android.support.design.R;
30import android.support.design.widget.FloatingActionButtonImpl.InternalVisibilityChangedListener;
31import android.support.v4.view.ViewCompat;
32import android.util.AttributeSet;
33import android.util.Log;
34import android.view.View;
35import android.widget.ImageView;
36
37import java.util.List;
38
39/**
40 * Floating action buttons are used for a special type of promoted action. They are distinguished
41 * by a circled icon floating above the UI and have special motion behaviors related to morphing,
42 * launching, and the transferring anchor point.
43 *
44 * <p>Floating action buttons come in two sizes: the default and the mini. The size can be
45 * controlled with the {@code fabSize} attribute.</p>
46 *
47 * <p>As this class descends from {@link ImageView}, you can control the icon which is displayed
48 * via {@link #setImageDrawable(Drawable)}.</p>
49 *
50 * <p>The background color of this view defaults to the your theme's {@code colorAccent}. If you
51 * wish to change this at runtime then you can do so via
52 * {@link #setBackgroundTintList(ColorStateList)}.</p>
53 *
54 * @attr ref android.support.design.R.styleable#FloatingActionButton_fabSize
55 */
56@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
57public class FloatingActionButton extends VisibilityAwareImageButton {
58
59    private static final String LOG_TAG = "FloatingActionButton";
60
61    /**
62     * Callback to be invoked when the visibility of a FloatingActionButton changes.
63     */
64    public abstract static class OnVisibilityChangedListener {
65        /**
66         * Called when a FloatingActionButton has been
67         * {@link #show(OnVisibilityChangedListener) shown}.
68         *
69         * @param fab the FloatingActionButton that was shown.
70         */
71        public void onShown(FloatingActionButton fab) {}
72
73        /**
74         * Called when a FloatingActionButton has been
75         * {@link #hide(OnVisibilityChangedListener) hidden}.
76         *
77         * @param fab the FloatingActionButton that was hidden.
78         */
79        public void onHidden(FloatingActionButton fab) {}
80    }
81
82    // These values must match those in the attrs declaration
83    private static final int SIZE_MINI = 1;
84    private static final int SIZE_NORMAL = 0;
85
86    private ColorStateList mBackgroundTint;
87    private PorterDuff.Mode mBackgroundTintMode;
88
89    private int mBorderWidth;
90    private int mRippleColor;
91    private int mSize;
92    private int mContentPadding;
93
94    private final Rect mShadowPadding;
95
96    private final FloatingActionButtonImpl mImpl;
97
98    public FloatingActionButton(Context context) {
99        this(context, null);
100    }
101
102    public FloatingActionButton(Context context, AttributeSet attrs) {
103        this(context, attrs, 0);
104    }
105
106    public FloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) {
107        super(context, attrs, defStyleAttr);
108
109        ThemeUtils.checkAppCompatTheme(context);
110
111        mShadowPadding = new Rect();
112
113        TypedArray a = context.obtainStyledAttributes(attrs,
114                R.styleable.FloatingActionButton, defStyleAttr,
115                R.style.Widget_Design_FloatingActionButton);
116        mBackgroundTint = a.getColorStateList(R.styleable.FloatingActionButton_backgroundTint);
117        mBackgroundTintMode = parseTintMode(a.getInt(
118                R.styleable.FloatingActionButton_backgroundTintMode, -1), null);
119        mRippleColor = a.getColor(R.styleable.FloatingActionButton_rippleColor, 0);
120        mSize = a.getInt(R.styleable.FloatingActionButton_fabSize, SIZE_NORMAL);
121        mBorderWidth = a.getDimensionPixelSize(R.styleable.FloatingActionButton_borderWidth, 0);
122        final float elevation = a.getDimension(R.styleable.FloatingActionButton_elevation, 0f);
123        final float pressedTranslationZ = a.getDimension(
124                R.styleable.FloatingActionButton_pressedTranslationZ, 0f);
125        a.recycle();
126
127        final ShadowViewDelegate delegate = new ShadowViewDelegate() {
128            @Override
129            public float getRadius() {
130                return getSizeDimension() / 2f;
131            }
132
133            @Override
134            public void setShadowPadding(int left, int top, int right, int bottom) {
135                mShadowPadding.set(left, top, right, bottom);
136
137                setPadding(left + mContentPadding, top + mContentPadding,
138                        right + mContentPadding, bottom + mContentPadding);
139            }
140
141            @Override
142            public void setBackgroundDrawable(Drawable background) {
143                FloatingActionButton.super.setBackgroundDrawable(background);
144            }
145        };
146
147        final int sdk = Build.VERSION.SDK_INT;
148        if (sdk >= 21) {
149            mImpl = new FloatingActionButtonLollipop(this, delegate);
150        } else if (sdk >= 14) {
151            mImpl = new FloatingActionButtonIcs(this, delegate);
152        } else {
153            mImpl = new FloatingActionButtonEclairMr1(this, delegate);
154        }
155
156        final int maxContentSize = (int) getResources().getDimension(
157                R.dimen.design_fab_content_size);
158        mContentPadding = (getSizeDimension() - maxContentSize) / 2;
159
160        mImpl.setBackgroundDrawable(mBackgroundTint, mBackgroundTintMode,
161                mRippleColor, mBorderWidth);
162        mImpl.setElevation(elevation);
163        mImpl.setPressedTranslationZ(pressedTranslationZ);
164    }
165
166    @Override
167    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
168        final int preferredSize = getSizeDimension();
169
170        final int w = resolveAdjustedSize(preferredSize, widthMeasureSpec);
171        final int h = resolveAdjustedSize(preferredSize, heightMeasureSpec);
172
173        // As we want to stay circular, we set both dimensions to be the
174        // smallest resolved dimension
175        final int d = Math.min(w, h);
176
177        // We add the shadow's padding to the measured dimension
178        setMeasuredDimension(
179                d + mShadowPadding.left + mShadowPadding.right,
180                d + mShadowPadding.top + mShadowPadding.bottom);
181    }
182
183    /**
184     * Set the ripple color for this {@link FloatingActionButton}.
185     * <p>
186     * When running on devices with KitKat or below, we draw a fill rather than a ripple.
187     *
188     * @param color ARGB color to use for the ripple.
189     */
190    public void setRippleColor(@ColorInt int color) {
191        if (mRippleColor != color) {
192            mRippleColor = color;
193            mImpl.setRippleColor(color);
194        }
195    }
196
197    /**
198     * Return the tint applied to the background drawable, if specified.
199     *
200     * @return the tint applied to the background drawable
201     * @see #setBackgroundTintList(ColorStateList)
202     */
203    @Nullable
204    @Override
205    public ColorStateList getBackgroundTintList() {
206        return mBackgroundTint;
207    }
208
209    /**
210     * Applies a tint to the background drawable. Does not modify the current tint
211     * mode, which is {@link PorterDuff.Mode#SRC_IN} by default.
212     *
213     * @param tint the tint to apply, may be {@code null} to clear tint
214     */
215    public void setBackgroundTintList(@Nullable ColorStateList tint) {
216        if (mBackgroundTint != tint) {
217            mBackgroundTint = tint;
218            mImpl.setBackgroundTintList(tint);
219        }
220    }
221
222
223    /**
224     * Return the blending mode used to apply the tint to the background
225     * drawable, if specified.
226     *
227     * @return the blending mode used to apply the tint to the background
228     *         drawable
229     * @see #setBackgroundTintMode(PorterDuff.Mode)
230     */
231    @Nullable
232    @Override
233    public PorterDuff.Mode getBackgroundTintMode() {
234        return mBackgroundTintMode;
235    }
236
237    /**
238     * Specifies the blending mode used to apply the tint specified by
239     * {@link #setBackgroundTintList(ColorStateList)}} to the background
240     * drawable. The default mode is {@link PorterDuff.Mode#SRC_IN}.
241     *
242     * @param tintMode the blending mode used to apply the tint, may be
243     *                 {@code null} to clear tint
244     */
245    public void setBackgroundTintMode(@Nullable PorterDuff.Mode tintMode) {
246        if (mBackgroundTintMode != tintMode) {
247            mBackgroundTintMode = tintMode;
248            mImpl.setBackgroundTintMode(tintMode);
249        }
250    }
251
252    @Override
253    public void setBackgroundDrawable(Drawable background) {
254        Log.i(LOG_TAG, "Setting a custom background is not supported.");
255    }
256
257    @Override
258    public void setBackgroundResource(int resid) {
259        Log.i(LOG_TAG, "Setting a custom background is not supported.");
260    }
261
262    @Override
263    public void setBackgroundColor(int color) {
264        Log.i(LOG_TAG, "Setting a custom background is not supported.");
265    }
266
267    /**
268     * Shows the button.
269     * <p>This method will animate the button show if the view has already been laid out.</p>
270     */
271    public void show() {
272        show(null);
273    }
274
275    /**
276     * Shows the button.
277     * <p>This method will animate the button show if the view has already been laid out.</p>
278     *
279     * @param listener the listener to notify when this view is shown
280     */
281    public void show(@Nullable final OnVisibilityChangedListener listener) {
282        show(listener, true);
283    }
284
285    private void show(OnVisibilityChangedListener listener, boolean fromUser) {
286        mImpl.show(wrapOnVisibilityChangedListener(listener), fromUser);
287    }
288
289    /**
290     * Hides the button.
291     * <p>This method will animate the button hide if the view has already been laid out.</p>
292     */
293    public void hide() {
294        hide(null);
295    }
296
297    /**
298     * Hides the button.
299     * <p>This method will animate the button hide if the view has already been laid out.</p>
300     *
301     * @param listener the listener to notify when this view is hidden
302     */
303    public void hide(@Nullable OnVisibilityChangedListener listener) {
304        hide(listener, true);
305    }
306
307    private void hide(@Nullable OnVisibilityChangedListener listener, boolean fromUser) {
308        mImpl.hide(wrapOnVisibilityChangedListener(listener), fromUser);
309    }
310
311    @Nullable
312    private InternalVisibilityChangedListener wrapOnVisibilityChangedListener(
313            @Nullable final OnVisibilityChangedListener listener) {
314        if (listener == null) {
315            return null;
316        }
317
318        return new InternalVisibilityChangedListener() {
319            @Override
320            public void onShown() {
321                listener.onShown(FloatingActionButton.this);
322            }
323
324            @Override
325            public void onHidden() {
326                listener.onHidden(FloatingActionButton.this);
327            }
328        };
329    }
330
331    final int getSizeDimension() {
332        switch (mSize) {
333            case SIZE_MINI:
334                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_mini);
335            case SIZE_NORMAL:
336            default:
337                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_normal);
338        }
339    }
340
341    @Override
342    protected void onAttachedToWindow() {
343        super.onAttachedToWindow();
344        mImpl.onAttachedToWindow();
345    }
346
347    @Override
348    protected void onDetachedFromWindow() {
349        super.onDetachedFromWindow();
350        mImpl.onDetachedFromWindow();
351    }
352
353    @Override
354    protected void drawableStateChanged() {
355        super.drawableStateChanged();
356        mImpl.onDrawableStateChanged(getDrawableState());
357    }
358
359    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
360    @Override
361    public void jumpDrawablesToCurrentState() {
362        super.jumpDrawablesToCurrentState();
363        mImpl.jumpDrawableToCurrentState();
364    }
365
366    private static int resolveAdjustedSize(int desiredSize, int measureSpec) {
367        int result = desiredSize;
368        int specMode = MeasureSpec.getMode(measureSpec);
369        int specSize = MeasureSpec.getSize(measureSpec);
370        switch (specMode) {
371            case MeasureSpec.UNSPECIFIED:
372                // Parent says we can be as big as we want. Just don't be larger
373                // than max size imposed on ourselves.
374                result = desiredSize;
375                break;
376            case MeasureSpec.AT_MOST:
377                // Parent says we can be as big as we want, up to specSize.
378                // Don't be larger than specSize, and don't be larger than
379                // the max size imposed on ourselves.
380                result = Math.min(desiredSize, specSize);
381                break;
382            case MeasureSpec.EXACTLY:
383                // No choice. Do what we are told.
384                result = specSize;
385                break;
386        }
387        return result;
388    }
389
390    static PorterDuff.Mode parseTintMode(int value, PorterDuff.Mode defaultMode) {
391        switch (value) {
392            case 3:
393                return PorterDuff.Mode.SRC_OVER;
394            case 5:
395                return PorterDuff.Mode.SRC_IN;
396            case 9:
397                return PorterDuff.Mode.SRC_ATOP;
398            case 14:
399                return PorterDuff.Mode.MULTIPLY;
400            case 15:
401                return PorterDuff.Mode.SCREEN;
402            default:
403                return defaultMode;
404        }
405    }
406
407    /**
408     * Behavior designed for use with {@link FloatingActionButton} instances. It's main function
409     * is to move {@link FloatingActionButton} views so that any displayed {@link Snackbar}s do
410     * not cover them.
411     */
412    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {
413        // We only support the FAB <> Snackbar shift movement on Honeycomb and above. This is
414        // because we can use view translation properties which greatly simplifies the code.
415        private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;
416
417        private ValueAnimatorCompat mFabTranslationYAnimator;
418        private float mFabTranslationY;
419        private Rect mTmpRect;
420
421        @Override
422        public boolean layoutDependsOn(CoordinatorLayout parent,
423                FloatingActionButton child, View dependency) {
424            // We're dependent on all SnackbarLayouts (if enabled)
425            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
426        }
427
428        @Override
429        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
430                View dependency) {
431            if (dependency instanceof Snackbar.SnackbarLayout) {
432                updateFabTranslationForSnackbar(parent, child, dependency);
433            } else if (dependency instanceof AppBarLayout) {
434                // If we're depending on an AppBarLayout we will show/hide it automatically
435                // if the FAB is anchored to the AppBarLayout
436                updateFabVisibility(parent, (AppBarLayout) dependency, child);
437            }
438            return false;
439        }
440
441        private boolean updateFabVisibility(CoordinatorLayout parent,
442                AppBarLayout appBarLayout, FloatingActionButton child) {
443            final CoordinatorLayout.LayoutParams lp =
444                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
445            if (lp.getAnchorId() != appBarLayout.getId()) {
446                // The anchor ID doesn't match the dependency, so we won't automatically
447                // show/hide the FAB
448                return false;
449            }
450
451            if (child.getUserSetVisibility() != VISIBLE) {
452                // The view isn't set to be visible so skip changing it's visibility
453                return false;
454            }
455
456            if (mTmpRect == null) {
457                mTmpRect = new Rect();
458            }
459
460            // First, let's get the visible rect of the dependency
461            final Rect rect = mTmpRect;
462            ViewGroupUtils.getDescendantRect(parent, appBarLayout, rect);
463
464            if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
465                // If the anchor's bottom is below the seam, we'll animate our FAB out
466                child.hide(null, false);
467            } else {
468                // Else, we'll animate our FAB back in
469                child.show(null, false);
470            }
471            return true;
472        }
473
474        private void updateFabTranslationForSnackbar(CoordinatorLayout parent,
475                final FloatingActionButton fab, View snackbar) {
476            if (fab.getVisibility() != View.VISIBLE) {
477                return;
478            }
479
480            final float targetTransY = getFabTranslationYForSnackbar(parent, fab);
481            if (mFabTranslationY == targetTransY) {
482                // We're already at (or currently animating to) the target value, return...
483                return;
484            }
485
486            final float currentTransY = ViewCompat.getTranslationY(fab);
487
488            // Make sure that any current animation is cancelled
489            if (mFabTranslationYAnimator != null && mFabTranslationYAnimator.isRunning()) {
490                mFabTranslationYAnimator.cancel();
491            }
492
493            if (Math.abs(currentTransY - targetTransY) > (fab.getHeight() * 0.667f)) {
494                // If the FAB will be travelling by more than 2/3 of it's height, let's animate
495                // it instead
496                if (mFabTranslationYAnimator == null) {
497                    mFabTranslationYAnimator = ViewUtils.createAnimator();
498                    mFabTranslationYAnimator.setInterpolator(
499                            AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
500                    mFabTranslationYAnimator.setUpdateListener(
501                            new ValueAnimatorCompat.AnimatorUpdateListener() {
502                                @Override
503                                public void onAnimationUpdate(ValueAnimatorCompat animator) {
504                                    ViewCompat.setTranslationY(fab,
505                                            animator.getAnimatedFloatValue());
506                                }
507                            });
508                }
509                mFabTranslationYAnimator.setFloatValues(currentTransY, targetTransY);
510                mFabTranslationYAnimator.start();
511            } else {
512                // Now update the translation Y
513                ViewCompat.setTranslationY(fab, targetTransY);
514            }
515
516            mFabTranslationY = targetTransY;
517        }
518
519        private float getFabTranslationYForSnackbar(CoordinatorLayout parent,
520                FloatingActionButton fab) {
521            float minOffset = 0;
522            final List<View> dependencies = parent.getDependencies(fab);
523            for (int i = 0, z = dependencies.size(); i < z; i++) {
524                final View view = dependencies.get(i);
525                if (view instanceof Snackbar.SnackbarLayout && parent.doViewsOverlap(fab, view)) {
526                    minOffset = Math.min(minOffset,
527                            ViewCompat.getTranslationY(view) - view.getHeight());
528                }
529            }
530
531            return minOffset;
532        }
533
534        @Override
535        public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
536                int layoutDirection) {
537            // First, lets make sure that the visibility of the FAB is consistent
538            final List<View> dependencies = parent.getDependencies(child);
539            for (int i = 0, count = dependencies.size(); i < count; i++) {
540                final View dependency = dependencies.get(i);
541                if (dependency instanceof AppBarLayout
542                        && updateFabVisibility(parent, (AppBarLayout) dependency, child)) {
543                    break;
544                }
545            }
546            // Now let the CoordinatorLayout lay out the FAB
547            parent.onLayoutChild(child, layoutDirection);
548            // Now offset it if needed
549            offsetIfNeeded(parent, child);
550            return true;
551        }
552
553        /**
554         * Pre-Lollipop we use padding so that the shadow has enough space to be drawn. This method
555         * offsets our layout position so that we're positioned correctly if we're on one of
556         * our parent's edges.
557         */
558        private void offsetIfNeeded(CoordinatorLayout parent, FloatingActionButton fab) {
559            final Rect padding = fab.mShadowPadding;
560
561            if (padding != null && padding.centerX() > 0 && padding.centerY() > 0) {
562                final CoordinatorLayout.LayoutParams lp =
563                        (CoordinatorLayout.LayoutParams) fab.getLayoutParams();
564
565                int offsetTB = 0, offsetLR = 0;
566
567                if (fab.getRight() >= parent.getWidth() - lp.rightMargin) {
568                    // If we're on the left edge, shift it the right
569                    offsetLR = padding.right;
570                } else if (fab.getLeft() <= lp.leftMargin) {
571                    // If we're on the left edge, shift it the left
572                    offsetLR = -padding.left;
573                }
574                if (fab.getBottom() >= parent.getBottom() - lp.bottomMargin) {
575                    // If we're on the bottom edge, shift it down
576                    offsetTB = padding.bottom;
577                } else if (fab.getTop() <= lp.topMargin) {
578                    // If we're on the top edge, shift it up
579                    offsetTB = -padding.top;
580                }
581
582                fab.offsetTopAndBottom(offsetTB);
583                fab.offsetLeftAndRight(offsetLR);
584            }
585        }
586    }
587}
588