1/*
2 * Copyright 2018 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 */
16package androidx.cardview.widget;
17
18import android.content.res.ColorStateList;
19import android.content.res.Resources;
20import android.graphics.Canvas;
21import android.graphics.Color;
22import android.graphics.ColorFilter;
23import android.graphics.LinearGradient;
24import android.graphics.Paint;
25import android.graphics.Path;
26import android.graphics.PixelFormat;
27import android.graphics.RadialGradient;
28import android.graphics.Rect;
29import android.graphics.RectF;
30import android.graphics.Shader;
31import android.graphics.drawable.Drawable;
32
33import androidx.annotation.Nullable;
34import androidx.cardview.R;
35
36/**
37 * A rounded rectangle drawable which also includes a shadow around.
38 */
39class RoundRectDrawableWithShadow extends Drawable {
40    // used to calculate content padding
41    private static final double COS_45 = Math.cos(Math.toRadians(45));
42
43    private static final float SHADOW_MULTIPLIER = 1.5f;
44
45    private final int mInsetShadow; // extra shadow to avoid gaps between card and shadow
46
47    /*
48    * This helper is set by CardView implementations.
49    * <p>
50    * Prior to API 17, canvas.drawRoundRect is expensive; which is why we need this interface
51    * to draw efficient rounded rectangles before 17.
52    * */
53    static RoundRectHelper sRoundRectHelper;
54
55    private Paint mPaint;
56
57    private Paint mCornerShadowPaint;
58
59    private Paint mEdgeShadowPaint;
60
61    private final RectF mCardBounds;
62
63    private float mCornerRadius;
64
65    private Path mCornerShadowPath;
66
67    // actual value set by developer
68    private float mRawMaxShadowSize;
69
70    // multiplied value to account for shadow offset
71    private float mShadowSize;
72
73    // actual value set by developer
74    private float mRawShadowSize;
75
76    private ColorStateList mBackground;
77
78    private boolean mDirty = true;
79
80    private final int mShadowStartColor;
81
82    private final int mShadowEndColor;
83
84    private boolean mAddPaddingForCorners = true;
85
86    /**
87     * If shadow size is set to a value above max shadow, we print a warning
88     */
89    private boolean mPrintedShadowClipWarning = false;
90
91    RoundRectDrawableWithShadow(Resources resources, ColorStateList backgroundColor, float radius,
92            float shadowSize, float maxShadowSize) {
93        mShadowStartColor = resources.getColor(R.color.cardview_shadow_start_color);
94        mShadowEndColor = resources.getColor(R.color.cardview_shadow_end_color);
95        mInsetShadow = resources.getDimensionPixelSize(R.dimen.cardview_compat_inset_shadow);
96        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
97        setBackground(backgroundColor);
98        mCornerShadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
99        mCornerShadowPaint.setStyle(Paint.Style.FILL);
100        mCornerRadius = (int) (radius + .5f);
101        mCardBounds = new RectF();
102        mEdgeShadowPaint = new Paint(mCornerShadowPaint);
103        mEdgeShadowPaint.setAntiAlias(false);
104        setShadowSize(shadowSize, maxShadowSize);
105    }
106
107    private void setBackground(ColorStateList color) {
108        mBackground = (color == null) ?  ColorStateList.valueOf(Color.TRANSPARENT) : color;
109        mPaint.setColor(mBackground.getColorForState(getState(), mBackground.getDefaultColor()));
110    }
111
112    /**
113     * Casts the value to an even integer.
114     */
115    private int toEven(float value) {
116        int i = (int) (value + .5f);
117        if (i % 2 == 1) {
118            return i - 1;
119        }
120        return i;
121    }
122
123    void setAddPaddingForCorners(boolean addPaddingForCorners) {
124        mAddPaddingForCorners = addPaddingForCorners;
125        invalidateSelf();
126    }
127
128    @Override
129    public void setAlpha(int alpha) {
130        mPaint.setAlpha(alpha);
131        mCornerShadowPaint.setAlpha(alpha);
132        mEdgeShadowPaint.setAlpha(alpha);
133    }
134
135    @Override
136    protected void onBoundsChange(Rect bounds) {
137        super.onBoundsChange(bounds);
138        mDirty = true;
139    }
140
141    private void setShadowSize(float shadowSize, float maxShadowSize) {
142        if (shadowSize < 0f) {
143            throw new IllegalArgumentException("Invalid shadow size " + shadowSize
144                    + ". Must be >= 0");
145        }
146        if (maxShadowSize < 0f) {
147            throw new IllegalArgumentException("Invalid max shadow size " + maxShadowSize
148                    + ". Must be >= 0");
149        }
150        shadowSize = toEven(shadowSize);
151        maxShadowSize = toEven(maxShadowSize);
152        if (shadowSize > maxShadowSize) {
153            shadowSize = maxShadowSize;
154            if (!mPrintedShadowClipWarning) {
155                mPrintedShadowClipWarning = true;
156            }
157        }
158        if (mRawShadowSize == shadowSize && mRawMaxShadowSize == maxShadowSize) {
159            return;
160        }
161        mRawShadowSize = shadowSize;
162        mRawMaxShadowSize = maxShadowSize;
163        mShadowSize = (int) (shadowSize * SHADOW_MULTIPLIER + mInsetShadow + .5f);
164        mDirty = true;
165        invalidateSelf();
166    }
167
168    @Override
169    public boolean getPadding(Rect padding) {
170        int vOffset = (int) Math.ceil(calculateVerticalPadding(mRawMaxShadowSize, mCornerRadius,
171                mAddPaddingForCorners));
172        int hOffset = (int) Math.ceil(calculateHorizontalPadding(mRawMaxShadowSize, mCornerRadius,
173                mAddPaddingForCorners));
174        padding.set(hOffset, vOffset, hOffset, vOffset);
175        return true;
176    }
177
178    static float calculateVerticalPadding(float maxShadowSize, float cornerRadius,
179            boolean addPaddingForCorners) {
180        if (addPaddingForCorners) {
181            return (float) (maxShadowSize * SHADOW_MULTIPLIER + (1 - COS_45) * cornerRadius);
182        } else {
183            return maxShadowSize * SHADOW_MULTIPLIER;
184        }
185    }
186
187    static float calculateHorizontalPadding(float maxShadowSize, float cornerRadius,
188            boolean addPaddingForCorners) {
189        if (addPaddingForCorners) {
190            return (float) (maxShadowSize + (1 - COS_45) * cornerRadius);
191        } else {
192            return maxShadowSize;
193        }
194    }
195
196    @Override
197    protected boolean onStateChange(int[] stateSet) {
198        final int newColor = mBackground.getColorForState(stateSet, mBackground.getDefaultColor());
199        if (mPaint.getColor() == newColor) {
200            return false;
201        }
202        mPaint.setColor(newColor);
203        mDirty = true;
204        invalidateSelf();
205        return true;
206    }
207
208    @Override
209    public boolean isStateful() {
210        return (mBackground != null && mBackground.isStateful()) || super.isStateful();
211    }
212
213    @Override
214    public void setColorFilter(ColorFilter cf) {
215        mPaint.setColorFilter(cf);
216    }
217
218    @Override
219    public int getOpacity() {
220        return PixelFormat.TRANSLUCENT;
221    }
222
223    void setCornerRadius(float radius) {
224        if (radius < 0f) {
225            throw new IllegalArgumentException("Invalid radius " + radius + ". Must be >= 0");
226        }
227        radius = (int) (radius + .5f);
228        if (mCornerRadius == radius) {
229            return;
230        }
231        mCornerRadius = radius;
232        mDirty = true;
233        invalidateSelf();
234    }
235
236    @Override
237    public void draw(Canvas canvas) {
238        if (mDirty) {
239            buildComponents(getBounds());
240            mDirty = false;
241        }
242        canvas.translate(0, mRawShadowSize / 2);
243        drawShadow(canvas);
244        canvas.translate(0, -mRawShadowSize / 2);
245        sRoundRectHelper.drawRoundRect(canvas, mCardBounds, mCornerRadius, mPaint);
246    }
247
248    private void drawShadow(Canvas canvas) {
249        final float edgeShadowTop = -mCornerRadius - mShadowSize;
250        final float inset = mCornerRadius + mInsetShadow + mRawShadowSize / 2;
251        final boolean drawHorizontalEdges = mCardBounds.width() - 2 * inset > 0;
252        final boolean drawVerticalEdges = mCardBounds.height() - 2 * inset > 0;
253        // LT
254        int saved = canvas.save();
255        canvas.translate(mCardBounds.left + inset, mCardBounds.top + inset);
256        canvas.drawPath(mCornerShadowPath, mCornerShadowPaint);
257        if (drawHorizontalEdges) {
258            canvas.drawRect(0, edgeShadowTop,
259                    mCardBounds.width() - 2 * inset, -mCornerRadius,
260                    mEdgeShadowPaint);
261        }
262        canvas.restoreToCount(saved);
263        // RB
264        saved = canvas.save();
265        canvas.translate(mCardBounds.right - inset, mCardBounds.bottom - inset);
266        canvas.rotate(180f);
267        canvas.drawPath(mCornerShadowPath, mCornerShadowPaint);
268        if (drawHorizontalEdges) {
269            canvas.drawRect(0, edgeShadowTop,
270                    mCardBounds.width() - 2 * inset, -mCornerRadius + mShadowSize,
271                    mEdgeShadowPaint);
272        }
273        canvas.restoreToCount(saved);
274        // LB
275        saved = canvas.save();
276        canvas.translate(mCardBounds.left + inset, mCardBounds.bottom - inset);
277        canvas.rotate(270f);
278        canvas.drawPath(mCornerShadowPath, mCornerShadowPaint);
279        if (drawVerticalEdges) {
280            canvas.drawRect(0, edgeShadowTop,
281                    mCardBounds.height() - 2 * inset, -mCornerRadius, mEdgeShadowPaint);
282        }
283        canvas.restoreToCount(saved);
284        // RT
285        saved = canvas.save();
286        canvas.translate(mCardBounds.right - inset, mCardBounds.top + inset);
287        canvas.rotate(90f);
288        canvas.drawPath(mCornerShadowPath, mCornerShadowPaint);
289        if (drawVerticalEdges) {
290            canvas.drawRect(0, edgeShadowTop,
291                    mCardBounds.height() - 2 * inset, -mCornerRadius, mEdgeShadowPaint);
292        }
293        canvas.restoreToCount(saved);
294    }
295
296    private void buildShadowCorners() {
297        RectF innerBounds = new RectF(-mCornerRadius, -mCornerRadius, mCornerRadius, mCornerRadius);
298        RectF outerBounds = new RectF(innerBounds);
299        outerBounds.inset(-mShadowSize, -mShadowSize);
300
301        if (mCornerShadowPath == null) {
302            mCornerShadowPath = new Path();
303        } else {
304            mCornerShadowPath.reset();
305        }
306        mCornerShadowPath.setFillType(Path.FillType.EVEN_ODD);
307        mCornerShadowPath.moveTo(-mCornerRadius, 0);
308        mCornerShadowPath.rLineTo(-mShadowSize, 0);
309        // outer arc
310        mCornerShadowPath.arcTo(outerBounds, 180f, 90f, false);
311        // inner arc
312        mCornerShadowPath.arcTo(innerBounds, 270f, -90f, false);
313        mCornerShadowPath.close();
314        float startRatio = mCornerRadius / (mCornerRadius + mShadowSize);
315        mCornerShadowPaint.setShader(new RadialGradient(0, 0, mCornerRadius + mShadowSize,
316                new int[]{mShadowStartColor, mShadowStartColor, mShadowEndColor},
317                new float[]{0f, startRatio, 1f},
318                Shader.TileMode.CLAMP));
319
320        // we offset the content shadowSize/2 pixels up to make it more realistic.
321        // this is why edge shadow shader has some extra space
322        // When drawing bottom edge shadow, we use that extra space.
323        mEdgeShadowPaint.setShader(new LinearGradient(0, -mCornerRadius + mShadowSize, 0,
324                -mCornerRadius - mShadowSize,
325                new int[]{mShadowStartColor, mShadowStartColor, mShadowEndColor},
326                new float[]{0f, .5f, 1f}, Shader.TileMode.CLAMP));
327        mEdgeShadowPaint.setAntiAlias(false);
328    }
329
330    private void buildComponents(Rect bounds) {
331        // Card is offset SHADOW_MULTIPLIER * maxShadowSize to account for the shadow shift.
332        // We could have different top-bottom offsets to avoid extra gap above but in that case
333        // center aligning Views inside the CardView would be problematic.
334        final float verticalOffset = mRawMaxShadowSize * SHADOW_MULTIPLIER;
335        mCardBounds.set(bounds.left + mRawMaxShadowSize, bounds.top + verticalOffset,
336                bounds.right - mRawMaxShadowSize, bounds.bottom - verticalOffset);
337        buildShadowCorners();
338    }
339
340    float getCornerRadius() {
341        return mCornerRadius;
342    }
343
344    void getMaxShadowAndCornerPadding(Rect into) {
345        getPadding(into);
346    }
347
348    void setShadowSize(float size) {
349        setShadowSize(size, mRawMaxShadowSize);
350    }
351
352    void setMaxShadowSize(float size) {
353        setShadowSize(mRawShadowSize, size);
354    }
355
356    float getShadowSize() {
357        return mRawShadowSize;
358    }
359
360    float getMaxShadowSize() {
361        return mRawMaxShadowSize;
362    }
363
364    float getMinWidth() {
365        final float content = 2
366                * Math.max(mRawMaxShadowSize, mCornerRadius + mInsetShadow + mRawMaxShadowSize / 2);
367        return content + (mRawMaxShadowSize + mInsetShadow) * 2;
368    }
369
370    float getMinHeight() {
371        final float content = 2 * Math.max(mRawMaxShadowSize, mCornerRadius + mInsetShadow
372                        + mRawMaxShadowSize * SHADOW_MULTIPLIER / 2);
373        return content + (mRawMaxShadowSize * SHADOW_MULTIPLIER + mInsetShadow) * 2;
374    }
375
376    void setColor(@Nullable ColorStateList color) {
377        setBackground(color);
378        invalidateSelf();
379    }
380
381    ColorStateList getColor() {
382        return mBackground;
383    }
384
385    interface RoundRectHelper {
386        void drawRoundRect(Canvas canvas, RectF bounds, float cornerRadius, Paint paint);
387    }
388}
389