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