FloatingActionButton.java revision e882ef3492de3d2bb687b454e08b870b06d8f4e2
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.Nullable;
28import android.support.design.R;
29import android.support.v4.view.ViewCompat;
30import android.support.v4.view.ViewPropertyAnimatorListener;
31import android.util.AttributeSet;
32import android.view.View;
33import android.view.animation.Animation;
34import android.widget.ImageView;
35
36import java.util.List;
37
38/**
39 * Floating action buttons are used for a special type of promoted action. They are distinguished
40 * by a circled icon floating above the UI and have special motion behaviors related to morphing,
41 * launching, and the transferring anchor point.
42 *
43 * <p>Floating action buttons come in two sizes: the default and the mini. The size can be
44 * controlled with the {@code fabSize} attribute.</p>
45 *
46 * <p>As this class descends from {@link ImageView}, you can control the icon which is displayed
47 * via {@link #setImageDrawable(Drawable)}.</p>
48 *
49 * <p>The background color of this view defaults to the your theme's {@code colorAccent}. If you
50 * wish to change this at runtime then you can do so via
51 * {@link #setBackgroundTintList(ColorStateList)}.</p>
52 *
53 * @attr ref android.support.design.R.styleable#FloatingActionButton_fabSize
54 */
55@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
56public class FloatingActionButton extends ImageView {
57
58    // These values must match those in the attrs declaration
59    private static final int SIZE_MINI = 1;
60    private static final int SIZE_NORMAL = 0;
61
62    private ColorStateList mBackgroundTint;
63    private PorterDuff.Mode mBackgroundTintMode;
64
65    private int mBorderWidth;
66    private int mRippleColor;
67    private int mSize;
68    private int mContentPadding;
69
70    private final Rect mShadowPadding;
71
72    private final FloatingActionButtonImpl mImpl;
73
74    public FloatingActionButton(Context context) {
75        this(context, null);
76    }
77
78    public FloatingActionButton(Context context, AttributeSet attrs) {
79        this(context, attrs, 0);
80    }
81
82    public FloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) {
83        super(context, attrs, defStyleAttr);
84
85        mShadowPadding = new Rect();
86
87        TypedArray a = context.obtainStyledAttributes(attrs,
88                R.styleable.FloatingActionButton, defStyleAttr,
89                R.style.Widget_Design_FloatingActionButton);
90        Drawable background = a.getDrawable(R.styleable.FloatingActionButton_android_background);
91        mBackgroundTint = a.getColorStateList(R.styleable.FloatingActionButton_backgroundTint);
92        mBackgroundTintMode = parseTintMode(a.getInt(
93                R.styleable.FloatingActionButton_backgroundTintMode, -1), null);
94        mRippleColor = a.getColor(R.styleable.FloatingActionButton_rippleColor, 0);
95        mSize = a.getInt(R.styleable.FloatingActionButton_fabSize, SIZE_NORMAL);
96        mBorderWidth = a.getDimensionPixelSize(R.styleable.FloatingActionButton_borderWidth, 0);
97        final float elevation = a.getDimension(R.styleable.FloatingActionButton_elevation, 0f);
98        final float pressedTranslationZ = a.getDimension(
99                R.styleable.FloatingActionButton_pressedTranslationZ, 0f);
100        a.recycle();
101
102        final ShadowViewDelegate delegate = new ShadowViewDelegate() {
103            @Override
104            public float getRadius() {
105                return getSizeDimension() / 2f;
106            }
107
108            @Override
109            public void setShadowPadding(int left, int top, int right, int bottom) {
110                mShadowPadding.set(left, top, right, bottom);
111
112                setPadding(left + mContentPadding, top + mContentPadding,
113                        right + mContentPadding, bottom + mContentPadding);
114            }
115
116            @Override
117            public void setBackgroundDrawable(Drawable background) {
118                FloatingActionButton.super.setBackgroundDrawable(background);
119            }
120        };
121
122        if (Build.VERSION.SDK_INT >= 21) {
123            mImpl = new FloatingActionButtonLollipop(this, delegate);
124        } else {
125            mImpl = new FloatingActionButtonEclairMr1(this, delegate);
126        }
127
128        final int maxContentSize = (int) getResources().getDimension(R.dimen.fab_content_size);
129        mContentPadding = (getSizeDimension() - maxContentSize) / 2;
130
131        mImpl.setBackgroundDrawable(background, mBackgroundTint,
132                mBackgroundTintMode, mRippleColor, mBorderWidth);
133        mImpl.setElevation(elevation);
134        mImpl.setPressedTranslationZ(pressedTranslationZ);
135
136        setClickable(true);
137    }
138
139    @Override
140    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
141        final int preferredSize = getSizeDimension();
142
143        final int w = resolveAdjustedSize(preferredSize, widthMeasureSpec);
144        final int h = resolveAdjustedSize(preferredSize, heightMeasureSpec);
145
146        // As we want to stay circular, we set both dimensions to be the
147        // smallest resolved dimension
148        final int d = Math.min(w, h);
149
150        // We add the shadow's padding to the measured dimension
151        setMeasuredDimension(
152                d + mShadowPadding.left + mShadowPadding.right,
153                d + mShadowPadding.top + mShadowPadding.bottom);
154    }
155
156    /**
157     * Set the ripple color for this {@link FloatingActionButton}.
158     * <p>
159     * When running on devices with KitKat or below, we draw a fill rather than a ripple.
160     *
161     * @param color ARGB color to use for the ripple.
162     */
163    public void setRippleColor(int color) {
164        if (mRippleColor != color) {
165            mRippleColor = color;
166            mImpl.setRippleColor(color);
167        }
168    }
169
170    /**
171     * Return the tint applied to the background drawable, if specified.
172     *
173     * @return the tint applied to the background drawable
174     * @see #setBackgroundTintList(ColorStateList)
175     */
176    @Nullable
177    @Override
178    public ColorStateList getBackgroundTintList() {
179        return mBackgroundTint;
180    }
181
182    /**
183     * Applies a tint to the background drawable. Does not modify the current tint
184     * mode, which is {@link PorterDuff.Mode#SRC_IN} by default.
185     *
186     * @param tint the tint to apply, may be {@code null} to clear tint
187     */
188    public void setBackgroundTintList(@Nullable ColorStateList tint) {
189        mImpl.setBackgroundTintList(tint);
190    }
191
192
193    /**
194     * Return the blending mode used to apply the tint to the background
195     * drawable, if specified.
196     *
197     * @return the blending mode used to apply the tint to the background
198     *         drawable
199     * @see #setBackgroundTintMode(PorterDuff.Mode)
200     */
201    @Nullable
202    @Override
203    public PorterDuff.Mode getBackgroundTintMode() {
204        return mBackgroundTintMode;
205    }
206
207    /**
208     * Specifies the blending mode used to apply the tint specified by
209     * {@link #setBackgroundTintList(ColorStateList)}} to the background
210     * drawable. The default mode is {@link PorterDuff.Mode#SRC_IN}.
211     *
212     * @param tintMode the blending mode used to apply the tint, may be
213     *                 {@code null} to clear tint
214     */
215    public void setBackgroundTintMode(@Nullable PorterDuff.Mode tintMode) {
216        mImpl.setBackgroundTintMode(tintMode);
217    }
218
219    @Override
220    public void setBackgroundDrawable(Drawable background) {
221        if (mImpl != null) {
222            mImpl.setBackgroundDrawable(
223                background, mBackgroundTint, mBackgroundTintMode, mRippleColor, mBorderWidth);
224        }
225    }
226
227    final int getSizeDimension() {
228        switch (mSize) {
229            case SIZE_MINI:
230                return getResources().getDimensionPixelSize(R.dimen.fab_size_mini);
231            case SIZE_NORMAL:
232            default:
233                return getResources().getDimensionPixelSize(R.dimen.fab_size_normal);
234        }
235    }
236
237    @Override
238    protected void drawableStateChanged() {
239        super.drawableStateChanged();
240        mImpl.onDrawableStateChanged(getDrawableState());
241    }
242
243    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
244    @Override
245    public void jumpDrawablesToCurrentState() {
246        super.jumpDrawablesToCurrentState();
247        mImpl.jumpDrawableToCurrentState();
248    }
249
250    private static int resolveAdjustedSize(int desiredSize, int measureSpec) {
251        int result = desiredSize;
252        int specMode = MeasureSpec.getMode(measureSpec);
253        int specSize = MeasureSpec.getSize(measureSpec);
254        switch (specMode) {
255            case MeasureSpec.UNSPECIFIED:
256                // Parent says we can be as big as we want. Just don't be larger
257                // than max size imposed on ourselves.
258                result = desiredSize;
259                break;
260            case MeasureSpec.AT_MOST:
261                // Parent says we can be as big as we want, up to specSize.
262                // Don't be larger than specSize, and don't be larger than
263                // the max size imposed on ourselves.
264                result = Math.min(desiredSize, specSize);
265                break;
266            case MeasureSpec.EXACTLY:
267                // No choice. Do what we are told.
268                result = specSize;
269                break;
270        }
271        return result;
272    }
273
274    static PorterDuff.Mode parseTintMode(int value, PorterDuff.Mode defaultMode) {
275        switch (value) {
276            case 3:
277                return PorterDuff.Mode.SRC_OVER;
278            case 5:
279                return PorterDuff.Mode.SRC_IN;
280            case 9:
281                return PorterDuff.Mode.SRC_ATOP;
282            case 14:
283                return PorterDuff.Mode.MULTIPLY;
284            case 15:
285                return PorterDuff.Mode.SCREEN;
286            default:
287                return defaultMode;
288        }
289    }
290
291    /**
292     * Behavior designed for use with {@link FloatingActionButton} instances. It's main function
293     * is to move {@link FloatingActionButton} views so that any displayed {@link Snackbar}s do
294     * not cover them.
295     */
296    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {
297        // We only support the FAB <> Snackbar shift movement on Honeycomb and above. This is
298        // because we can use view translation properties which greatly simplifies the code.
299        private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;
300
301        private Rect mTmpRect;
302        private boolean mIsAnimatingOut;
303        private float mTranslationY;
304
305        @Override
306        public boolean layoutDependsOn(CoordinatorLayout parent,
307                FloatingActionButton child,
308                View dependency) {
309            // We're dependent on all SnackbarLayouts (if enabled)
310            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
311        }
312
313        @Override
314        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
315                View dependency) {
316            if (dependency instanceof Snackbar.SnackbarLayout) {
317                updateFabTranslationForSnackbar(parent, child, dependency);
318            } else if (dependency instanceof AppBarLayout) {
319                final AppBarLayout appBarLayout = (AppBarLayout) dependency;
320                if (mTmpRect == null) {
321                    mTmpRect = new Rect();
322                }
323
324                // First, let's get the visible rect of the dependency
325                final Rect rect = mTmpRect;
326                ViewGroupUtils.getDescendantRect(parent, dependency, rect);
327
328                if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
329                    // If the anchor's bottom is below the seam, we'll animate our FAB out
330                    if (!mIsAnimatingOut && child.getVisibility() == View.VISIBLE) {
331                        animateOut(child);
332                    }
333                } else {
334                    // Else, we'll animate our FAB back in
335                    if (child.getVisibility() != View.VISIBLE) {
336                        animateIn(child);
337                    }
338                }
339            }
340            return false;
341        }
342
343        private void updateFabTranslationForSnackbar(CoordinatorLayout parent,
344                FloatingActionButton fab, View snackbar) {
345            final float translationY = getFabTranslationYForSnackbar(parent, fab);
346            if (translationY != mTranslationY) {
347                // First, cancel any current animation
348                ViewCompat.animate(fab).cancel();
349
350                if (Math.abs(translationY - mTranslationY) == snackbar.getHeight()) {
351                    // If we're travelling by the height of the Snackbar then we probably need to
352                    // animate to the value
353                    ViewCompat.animate(fab)
354                            .translationY(translationY)
355                            .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
356                            .setListener(null);
357                } else {
358                    // Else we'll set use setTranslationY
359                    ViewCompat.setTranslationY(fab, translationY);
360                }
361                mTranslationY = translationY;
362            }
363        }
364
365        private float getFabTranslationYForSnackbar(CoordinatorLayout parent,
366                FloatingActionButton fab) {
367            float minOffset = 0;
368            final List<View> dependencies = parent.getDependencies(fab);
369            for (int i = 0, z = dependencies.size(); i < z; i++) {
370                final View view = dependencies.get(i);
371                if (view instanceof Snackbar.SnackbarLayout && parent.doViewsOverlap(fab, view)) {
372                    minOffset = Math.min(minOffset,
373                            ViewCompat.getTranslationY(view) - view.getHeight());
374                }
375            }
376
377            return minOffset;
378        }
379
380        private void animateIn(FloatingActionButton button) {
381            button.setVisibility(View.VISIBLE);
382
383            if (Build.VERSION.SDK_INT >= 14) {
384                ViewCompat.animate(button)
385                        .scaleX(1f)
386                        .scaleY(1f)
387                        .alpha(1f)
388                        .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
389                        .withLayer()
390                        .setListener(null)
391                        .start();
392            } else {
393                Animation anim = android.view.animation.AnimationUtils.loadAnimation(
394                        button.getContext(), R.anim.fab_in);
395                anim.setDuration(200);
396                anim.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
397                button.startAnimation(anim);
398            }
399        }
400
401        @Override
402        public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
403                int layoutDirection) {
404            // Let the CoordinatorLayout lay out the FAB
405            parent.onLayoutChild(child, layoutDirection);
406            // Now offset it if needed
407            offsetIfNeeded(parent, child);
408            return true;
409        }
410
411        private void animateOut(final FloatingActionButton button) {
412            if (Build.VERSION.SDK_INT >= 14) {
413                ViewCompat.animate(button)
414                        .scaleX(0f)
415                        .scaleY(0f)
416                        .alpha(0f)
417                        .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
418                        .withLayer()
419                        .setListener(new ViewPropertyAnimatorListener() {
420                            @Override
421                            public void onAnimationStart(View view) {
422                                mIsAnimatingOut = true;
423                            }
424
425                            @Override
426                            public void onAnimationCancel(View view) {
427                                mIsAnimatingOut = false;
428                            }
429
430                            @Override
431                            public void onAnimationEnd(View view) {
432                                mIsAnimatingOut = false;
433                                view.setVisibility(View.GONE);
434                            }
435                        }).start();
436            } else {
437                Animation anim = android.view.animation.AnimationUtils.loadAnimation(
438                        button.getContext(), R.anim.fab_out);
439                anim.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
440                anim.setDuration(200);
441                anim.setAnimationListener(new AnimationUtils.AnimationListenerAdapter() {
442                    @Override
443                    public void onAnimationStart(Animation animation) {
444                        mIsAnimatingOut = true;
445                    }
446
447                    @Override
448                    public void onAnimationEnd(Animation animation) {
449                        mIsAnimatingOut = false;
450                        button.setVisibility(View.GONE);
451                    }
452                });
453                button.startAnimation(anim);
454            }
455        }
456
457        /**
458         * Pre-Lollipop we use padding so that the shadow has enough space to be drawn. This method
459         * offsets our layout position so that we're positioned correctly if we're on one of
460         * our parent's edges.
461         */
462        private void offsetIfNeeded(CoordinatorLayout parent, FloatingActionButton fab) {
463            final Rect padding = fab.mShadowPadding;
464
465            if (padding != null && padding.centerX() > 0 && padding.centerY() > 0) {
466                final CoordinatorLayout.LayoutParams lp =
467                        (CoordinatorLayout.LayoutParams) fab.getLayoutParams();
468
469                int offsetTB = 0, offsetLR = 0;
470
471                if (fab.getRight() >= parent.getWidth() - lp.rightMargin) {
472                    // If we're on the left edge, shift it the right
473                    offsetLR = padding.right;
474                } else if (fab.getLeft() <= lp.leftMargin) {
475                    // If we're on the left edge, shift it the left
476                    offsetLR = -padding.left;
477                }
478                if (fab.getBottom() >= parent.getBottom() - lp.bottomMargin) {
479                    // If we're on the bottom edge, shift it down
480                    offsetTB = padding.bottom;
481                } else if (fab.getTop() <= lp.topMargin) {
482                    // If we're on the top edge, shift it up
483                    offsetTB = -padding.top;
484                }
485
486                fab.offsetTopAndBottom(offsetTB);
487                fab.offsetLeftAndRight(offsetLR);
488            }
489        }
490    }
491}
492