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