FloatingActionButton.java revision 7a13c8489daca7915623dd673df49de2d1a0bf30
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.NonNull;
29import android.support.annotation.Nullable;
30import android.support.design.R;
31import android.support.v4.view.ViewCompat;
32import android.util.AttributeSet;
33import android.view.View;
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        final int sdk = Build.VERSION.SDK_INT;
123        if (sdk >= 21) {
124            mImpl = new FloatingActionButtonLollipop(this, delegate);
125        } else if (sdk >= 12) {
126            mImpl = new FloatingActionButtonHoneycombMr1(this, delegate);
127        } else {
128            mImpl = new FloatingActionButtonEclairMr1(this, delegate);
129        }
130
131        final int maxContentSize = (int) getResources().getDimension(
132                R.dimen.design_fab_content_size);
133        mContentPadding = (getSizeDimension() - maxContentSize) / 2;
134
135        mImpl.setBackgroundDrawable(background, mBackgroundTint,
136                mBackgroundTintMode, mRippleColor, mBorderWidth);
137        mImpl.setElevation(elevation);
138        mImpl.setPressedTranslationZ(pressedTranslationZ);
139
140        setClickable(true);
141    }
142
143    @Override
144    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
145        final int preferredSize = getSizeDimension();
146
147        final int w = resolveAdjustedSize(preferredSize, widthMeasureSpec);
148        final int h = resolveAdjustedSize(preferredSize, heightMeasureSpec);
149
150        // As we want to stay circular, we set both dimensions to be the
151        // smallest resolved dimension
152        final int d = Math.min(w, h);
153
154        // We add the shadow's padding to the measured dimension
155        setMeasuredDimension(
156                d + mShadowPadding.left + mShadowPadding.right,
157                d + mShadowPadding.top + mShadowPadding.bottom);
158    }
159
160    /**
161     * Set the ripple color for this {@link FloatingActionButton}.
162     * <p>
163     * When running on devices with KitKat or below, we draw a fill rather than a ripple.
164     *
165     * @param color ARGB color to use for the ripple.
166     */
167    public void setRippleColor(@ColorInt int color) {
168        if (mRippleColor != color) {
169            mRippleColor = color;
170            mImpl.setRippleColor(color);
171        }
172    }
173
174    /**
175     * Return the tint applied to the background drawable, if specified.
176     *
177     * @return the tint applied to the background drawable
178     * @see #setBackgroundTintList(ColorStateList)
179     */
180    @Nullable
181    @Override
182    public ColorStateList getBackgroundTintList() {
183        return mBackgroundTint;
184    }
185
186    /**
187     * Applies a tint to the background drawable. Does not modify the current tint
188     * mode, which is {@link PorterDuff.Mode#SRC_IN} by default.
189     *
190     * @param tint the tint to apply, may be {@code null} to clear tint
191     */
192    public void setBackgroundTintList(@Nullable ColorStateList tint) {
193        if (mBackgroundTint != tint) {
194            mBackgroundTint = tint;
195            mImpl.setBackgroundTintList(tint);
196        }
197    }
198
199
200    /**
201     * Return the blending mode used to apply the tint to the background
202     * drawable, if specified.
203     *
204     * @return the blending mode used to apply the tint to the background
205     *         drawable
206     * @see #setBackgroundTintMode(PorterDuff.Mode)
207     */
208    @Nullable
209    @Override
210    public PorterDuff.Mode getBackgroundTintMode() {
211        return mBackgroundTintMode;
212    }
213
214    /**
215     * Specifies the blending mode used to apply the tint specified by
216     * {@link #setBackgroundTintList(ColorStateList)}} to the background
217     * drawable. The default mode is {@link PorterDuff.Mode#SRC_IN}.
218     *
219     * @param tintMode the blending mode used to apply the tint, may be
220     *                 {@code null} to clear tint
221     */
222    public void setBackgroundTintMode(@Nullable PorterDuff.Mode tintMode) {
223        if (mBackgroundTintMode != tintMode) {
224            mBackgroundTintMode = tintMode;
225            mImpl.setBackgroundTintMode(tintMode);
226        }
227    }
228
229    @Override
230    public void setBackgroundDrawable(@NonNull Drawable background) {
231        if (mImpl != null) {
232            mImpl.setBackgroundDrawable(
233                    background, mBackgroundTint, mBackgroundTintMode, mRippleColor, mBorderWidth);
234        }
235    }
236
237    /**
238     * Shows the button.
239     * <p>This method will animate it the button show if the view has already been laid out.</p>
240     */
241    public void show() {
242        if (getVisibility() == VISIBLE) {
243            return;
244        }
245        setVisibility(VISIBLE);
246        if (ViewCompat.isLaidOut(this)) {
247            mImpl.show();
248        }
249    }
250
251    /**
252     * Hides the button.
253     * <p>This method will animate the button hide if the view has already been laid out.</p>
254     */
255    public void hide() {
256        if (getVisibility() != VISIBLE) {
257            return;
258        }
259        if (ViewCompat.isLaidOut(this) && !isInEditMode()) {
260            mImpl.hide();
261        } else {
262            setVisibility(GONE);
263        }
264    }
265
266    final int getSizeDimension() {
267        switch (mSize) {
268            case SIZE_MINI:
269                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_mini);
270            case SIZE_NORMAL:
271            default:
272                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_normal);
273        }
274    }
275
276    @Override
277    protected void drawableStateChanged() {
278        super.drawableStateChanged();
279        mImpl.onDrawableStateChanged(getDrawableState());
280    }
281
282    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
283    @Override
284    public void jumpDrawablesToCurrentState() {
285        super.jumpDrawablesToCurrentState();
286        mImpl.jumpDrawableToCurrentState();
287    }
288
289    private static int resolveAdjustedSize(int desiredSize, int measureSpec) {
290        int result = desiredSize;
291        int specMode = MeasureSpec.getMode(measureSpec);
292        int specSize = MeasureSpec.getSize(measureSpec);
293        switch (specMode) {
294            case MeasureSpec.UNSPECIFIED:
295                // Parent says we can be as big as we want. Just don't be larger
296                // than max size imposed on ourselves.
297                result = desiredSize;
298                break;
299            case MeasureSpec.AT_MOST:
300                // Parent says we can be as big as we want, up to specSize.
301                // Don't be larger than specSize, and don't be larger than
302                // the max size imposed on ourselves.
303                result = Math.min(desiredSize, specSize);
304                break;
305            case MeasureSpec.EXACTLY:
306                // No choice. Do what we are told.
307                result = specSize;
308                break;
309        }
310        return result;
311    }
312
313    static PorterDuff.Mode parseTintMode(int value, PorterDuff.Mode defaultMode) {
314        switch (value) {
315            case 3:
316                return PorterDuff.Mode.SRC_OVER;
317            case 5:
318                return PorterDuff.Mode.SRC_IN;
319            case 9:
320                return PorterDuff.Mode.SRC_ATOP;
321            case 14:
322                return PorterDuff.Mode.MULTIPLY;
323            case 15:
324                return PorterDuff.Mode.SCREEN;
325            default:
326                return defaultMode;
327        }
328    }
329
330    /**
331     * Behavior designed for use with {@link FloatingActionButton} instances. It's main function
332     * is to move {@link FloatingActionButton} views so that any displayed {@link Snackbar}s do
333     * not cover them.
334     */
335    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {
336        // We only support the FAB <> Snackbar shift movement on Honeycomb and above. This is
337        // because we can use view translation properties which greatly simplifies the code.
338        private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;
339
340        private Rect mTmpRect;
341        private float mTranslationY;
342
343        @Override
344        public boolean layoutDependsOn(CoordinatorLayout parent,
345                FloatingActionButton child, View dependency) {
346            // We're dependent on all SnackbarLayouts (if enabled)
347            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
348        }
349
350        @Override
351        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
352                View dependency) {
353            if (dependency instanceof Snackbar.SnackbarLayout) {
354                updateFabTranslationForSnackbar(parent, child, dependency);
355            } else if (dependency instanceof AppBarLayout) {
356                // If we're depending on an AppBarLayout we will show/hide it automatically
357                // if the FAB is anchored to the AppBarLayout
358                updateFabVisibility(parent, (AppBarLayout) dependency, child);
359            }
360            return false;
361        }
362
363        @Override
364        public void onDependentViewRemoved(CoordinatorLayout parent, FloatingActionButton child,
365                View dependency) {
366            if (dependency instanceof Snackbar.SnackbarLayout) {
367                // If the removed view is a SnackbarLayout, we will animate back to our normal
368                // position
369                ViewCompat.animate(child)
370                        .translationY(0f)
371                        .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
372                        .setListener(null);
373            }
374        }
375
376        private boolean updateFabVisibility(CoordinatorLayout parent,
377                AppBarLayout appBarLayout, FloatingActionButton child) {
378            final CoordinatorLayout.LayoutParams lp =
379                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
380            if (lp.getAnchorId() != appBarLayout.getId()) {
381                // The anchor ID doesn't match the dependency, so we won't automatically
382                // show/hide the FAB
383                return false;
384            }
385
386            if (mTmpRect == null) {
387                mTmpRect = new Rect();
388            }
389
390            // First, let's get the visible rect of the dependency
391            final Rect rect = mTmpRect;
392            ViewGroupUtils.getDescendantRect(parent, appBarLayout, rect);
393
394            if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
395                // If the anchor's bottom is below the seam, we'll animate our FAB out
396                child.hide();
397            } else {
398                // Else, we'll animate our FAB back in
399                child.show();
400            }
401            return true;
402        }
403
404        private void updateFabTranslationForSnackbar(CoordinatorLayout parent,
405                FloatingActionButton fab, View snackbar) {
406            if (fab.getVisibility() != View.VISIBLE) {
407                return;
408            }
409
410            final float translationY = getFabTranslationYForSnackbar(parent, fab);
411            if (translationY != mTranslationY) {
412                // First, cancel any current animation
413                ViewCompat.animate(fab).cancel();
414                // Else we'll set use setTranslationY
415                ViewCompat.setTranslationY(fab, translationY);
416                mTranslationY = translationY;
417            }
418        }
419
420        private float getFabTranslationYForSnackbar(CoordinatorLayout parent,
421                FloatingActionButton fab) {
422            float minOffset = 0;
423            final List<View> dependencies = parent.getDependencies(fab);
424            for (int i = 0, z = dependencies.size(); i < z; i++) {
425                final View view = dependencies.get(i);
426                if (view instanceof Snackbar.SnackbarLayout && parent.doViewsOverlap(fab, view)) {
427                    minOffset = Math.min(minOffset,
428                            ViewCompat.getTranslationY(view) - view.getHeight());
429                }
430            }
431
432            return minOffset;
433        }
434
435        @Override
436        public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
437                int layoutDirection) {
438            // First, lets make sure that the visibility of the FAB is consistent
439            final List<View> dependencies = parent.getDependencies(child);
440            for (int i = 0, count = dependencies.size(); i < count; i++) {
441                final View dependency = dependencies.get(i);
442                if (dependency instanceof AppBarLayout
443                        && updateFabVisibility(parent, (AppBarLayout) dependency, child)) {
444                    break;
445                }
446            }
447            // Now let the CoordinatorLayout lay out the FAB
448            parent.onLayoutChild(child, layoutDirection);
449            // Now offset it if needed
450            offsetIfNeeded(parent, child);
451            return true;
452        }
453
454        /**
455         * Pre-Lollipop we use padding so that the shadow has enough space to be drawn. This method
456         * offsets our layout position so that we're positioned correctly if we're on one of
457         * our parent's edges.
458         */
459        private void offsetIfNeeded(CoordinatorLayout parent, FloatingActionButton fab) {
460            final Rect padding = fab.mShadowPadding;
461
462            if (padding != null && padding.centerX() > 0 && padding.centerY() > 0) {
463                final CoordinatorLayout.LayoutParams lp =
464                        (CoordinatorLayout.LayoutParams) fab.getLayoutParams();
465
466                int offsetTB = 0, offsetLR = 0;
467
468                if (fab.getRight() >= parent.getWidth() - lp.rightMargin) {
469                    // If we're on the left edge, shift it the right
470                    offsetLR = padding.right;
471                } else if (fab.getLeft() <= lp.leftMargin) {
472                    // If we're on the left edge, shift it the left
473                    offsetLR = -padding.left;
474                }
475                if (fab.getBottom() >= parent.getBottom() - lp.bottomMargin) {
476                    // If we're on the bottom edge, shift it down
477                    offsetTB = padding.bottom;
478                } else if (fab.getTop() <= lp.topMargin) {
479                    // If we're on the top edge, shift it up
480                    offsetTB = -padding.top;
481                }
482
483                fab.offsetTopAndBottom(offsetTB);
484                fab.offsetLeftAndRight(offsetLR);
485            }
486        }
487    }
488}
489