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        mImpl.show();
243    }
244
245    /**
246     * Hides the button.
247     * <p>This method will animate the button hide if the view has already been laid out.</p>
248     */
249    public void hide() {
250        mImpl.hide();
251    }
252
253    final int getSizeDimension() {
254        switch (mSize) {
255            case SIZE_MINI:
256                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_mini);
257            case SIZE_NORMAL:
258            default:
259                return getResources().getDimensionPixelSize(R.dimen.design_fab_size_normal);
260        }
261    }
262
263    @Override
264    protected void drawableStateChanged() {
265        super.drawableStateChanged();
266        mImpl.onDrawableStateChanged(getDrawableState());
267    }
268
269    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
270    @Override
271    public void jumpDrawablesToCurrentState() {
272        super.jumpDrawablesToCurrentState();
273        mImpl.jumpDrawableToCurrentState();
274    }
275
276    private static int resolveAdjustedSize(int desiredSize, int measureSpec) {
277        int result = desiredSize;
278        int specMode = MeasureSpec.getMode(measureSpec);
279        int specSize = MeasureSpec.getSize(measureSpec);
280        switch (specMode) {
281            case MeasureSpec.UNSPECIFIED:
282                // Parent says we can be as big as we want. Just don't be larger
283                // than max size imposed on ourselves.
284                result = desiredSize;
285                break;
286            case MeasureSpec.AT_MOST:
287                // Parent says we can be as big as we want, up to specSize.
288                // Don't be larger than specSize, and don't be larger than
289                // the max size imposed on ourselves.
290                result = Math.min(desiredSize, specSize);
291                break;
292            case MeasureSpec.EXACTLY:
293                // No choice. Do what we are told.
294                result = specSize;
295                break;
296        }
297        return result;
298    }
299
300    static PorterDuff.Mode parseTintMode(int value, PorterDuff.Mode defaultMode) {
301        switch (value) {
302            case 3:
303                return PorterDuff.Mode.SRC_OVER;
304            case 5:
305                return PorterDuff.Mode.SRC_IN;
306            case 9:
307                return PorterDuff.Mode.SRC_ATOP;
308            case 14:
309                return PorterDuff.Mode.MULTIPLY;
310            case 15:
311                return PorterDuff.Mode.SCREEN;
312            default:
313                return defaultMode;
314        }
315    }
316
317    /**
318     * Behavior designed for use with {@link FloatingActionButton} instances. It's main function
319     * is to move {@link FloatingActionButton} views so that any displayed {@link Snackbar}s do
320     * not cover them.
321     */
322    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {
323        // We only support the FAB <> Snackbar shift movement on Honeycomb and above. This is
324        // because we can use view translation properties which greatly simplifies the code.
325        private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;
326
327        private Rect mTmpRect;
328
329        @Override
330        public boolean layoutDependsOn(CoordinatorLayout parent,
331                FloatingActionButton child, View dependency) {
332            // We're dependent on all SnackbarLayouts (if enabled)
333            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
334        }
335
336        @Override
337        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
338                View dependency) {
339            if (dependency instanceof Snackbar.SnackbarLayout) {
340                updateFabTranslationForSnackbar(parent, child, dependency);
341            } else if (dependency instanceof AppBarLayout) {
342                // If we're depending on an AppBarLayout we will show/hide it automatically
343                // if the FAB is anchored to the AppBarLayout
344                updateFabVisibility(parent, (AppBarLayout) dependency, child);
345            }
346            return false;
347        }
348
349        @Override
350        public void onDependentViewRemoved(CoordinatorLayout parent, FloatingActionButton child,
351                View dependency) {
352            if (dependency instanceof Snackbar.SnackbarLayout) {
353                // If the removed view is a SnackbarLayout, we will animate back to our normal
354                // position
355                if (ViewCompat.getTranslationY(child) != 0f) {
356                    ViewCompat.animate(child)
357                            .translationY(0f)
358                            .scaleX(1f)
359                            .scaleY(1f)
360                            .alpha(1f)
361                            .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
362                            .setListener(null);
363                }
364            }
365        }
366
367        private boolean updateFabVisibility(CoordinatorLayout parent,
368                AppBarLayout appBarLayout, FloatingActionButton child) {
369            final CoordinatorLayout.LayoutParams lp =
370                    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
371            if (lp.getAnchorId() != appBarLayout.getId()) {
372                // The anchor ID doesn't match the dependency, so we won't automatically
373                // show/hide the FAB
374                return false;
375            }
376
377            if (mTmpRect == null) {
378                mTmpRect = new Rect();
379            }
380
381            // First, let's get the visible rect of the dependency
382            final Rect rect = mTmpRect;
383            ViewGroupUtils.getDescendantRect(parent, appBarLayout, rect);
384
385            if (rect.bottom <= appBarLayout.getMinimumHeightForVisibleOverlappingContent()) {
386                // If the anchor's bottom is below the seam, we'll animate our FAB out
387                child.hide();
388            } else {
389                // Else, we'll animate our FAB back in
390                child.show();
391            }
392            return true;
393        }
394
395        private void updateFabTranslationForSnackbar(CoordinatorLayout parent,
396                FloatingActionButton fab, View snackbar) {
397            if (fab.getVisibility() != View.VISIBLE) {
398                return;
399            }
400
401            final float translationY = getFabTranslationYForSnackbar(parent, fab);
402            ViewCompat.setTranslationY(fab, translationY);
403        }
404
405        private float getFabTranslationYForSnackbar(CoordinatorLayout parent,
406                FloatingActionButton fab) {
407            float minOffset = 0;
408            final List<View> dependencies = parent.getDependencies(fab);
409            for (int i = 0, z = dependencies.size(); i < z; i++) {
410                final View view = dependencies.get(i);
411                if (view instanceof Snackbar.SnackbarLayout && parent.doViewsOverlap(fab, view)) {
412                    minOffset = Math.min(minOffset,
413                            ViewCompat.getTranslationY(view) - view.getHeight());
414                }
415            }
416
417            return minOffset;
418        }
419
420        @Override
421        public boolean onLayoutChild(CoordinatorLayout parent, FloatingActionButton child,
422                int layoutDirection) {
423            // First, lets make sure that the visibility of the FAB is consistent
424            final List<View> dependencies = parent.getDependencies(child);
425            for (int i = 0, count = dependencies.size(); i < count; i++) {
426                final View dependency = dependencies.get(i);
427                if (dependency instanceof AppBarLayout
428                        && updateFabVisibility(parent, (AppBarLayout) dependency, child)) {
429                    break;
430                }
431            }
432            // Now let the CoordinatorLayout lay out the FAB
433            parent.onLayoutChild(child, layoutDirection);
434            // Now offset it if needed
435            offsetIfNeeded(parent, child);
436            return true;
437        }
438
439        /**
440         * Pre-Lollipop we use padding so that the shadow has enough space to be drawn. This method
441         * offsets our layout position so that we're positioned correctly if we're on one of
442         * our parent's edges.
443         */
444        private void offsetIfNeeded(CoordinatorLayout parent, FloatingActionButton fab) {
445            final Rect padding = fab.mShadowPadding;
446
447            if (padding != null && padding.centerX() > 0 && padding.centerY() > 0) {
448                final CoordinatorLayout.LayoutParams lp =
449                        (CoordinatorLayout.LayoutParams) fab.getLayoutParams();
450
451                int offsetTB = 0, offsetLR = 0;
452
453                if (fab.getRight() >= parent.getWidth() - lp.rightMargin) {
454                    // If we're on the left edge, shift it the right
455                    offsetLR = padding.right;
456                } else if (fab.getLeft() <= lp.leftMargin) {
457                    // If we're on the left edge, shift it the left
458                    offsetLR = -padding.left;
459                }
460                if (fab.getBottom() >= parent.getBottom() - lp.bottomMargin) {
461                    // If we're on the bottom edge, shift it down
462                    offsetTB = padding.bottom;
463                } else if (fab.getTop() <= lp.topMargin) {
464                    // If we're on the top edge, shift it up
465                    offsetTB = -padding.top;
466                }
467
468                fab.offsetTopAndBottom(offsetTB);
469                fab.offsetLeftAndRight(offsetLR);
470            }
471        }
472    }
473}
474