1 /*
2  * Copyright (C) 2017 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 package androidx.wear.widget;
17 
18 import android.content.res.Resources;
19 import android.content.res.TypedArray;
20 import android.graphics.Bitmap;
21 import android.graphics.BitmapShader;
22 import android.graphics.Canvas;
23 import android.graphics.Color;
24 import android.graphics.ColorFilter;
25 import android.graphics.Paint;
26 import android.graphics.PixelFormat;
27 import android.graphics.Rect;
28 import android.graphics.RectF;
29 import android.graphics.Shader;
30 import android.graphics.drawable.Drawable;
31 import android.util.AttributeSet;
32 import android.util.Xml;
33 
34 import androidx.annotation.ColorInt;
35 import androidx.annotation.VisibleForTesting;
36 import androidx.wear.R;
37 
38 import org.jspecify.annotations.NonNull;
39 import org.jspecify.annotations.Nullable;
40 import org.xmlpull.v1.XmlPullParser;
41 import org.xmlpull.v1.XmlPullParserException;
42 
43 import java.io.IOException;
44 import java.util.Objects;
45 
46 /**
47  * Maintains and draws a drawable inside rounded rectangular bounds.
48  *
49  * <p>The drawable set by the {@link #setDrawable(Drawable)} method will be drawn within the rounded
50  * bounds specified by {@link #setBounds(Rect)} and {@link #setRadius(int)} when the
51  * {@link #draw(Canvas)} method is called.
52  *
53  * <p>By default, RoundedDrawable will apply padding to the drawable inside to fit the drawable into
54  * the rounded rectangle. If clipping is enabled by the {@link #setClipEnabled(boolean)} method, it
55  * will clip the drawable to a rounded rectangle instead of resizing it.
56  *
57  * <p>The {@link #setRadius(int)} method is used to specify the amount of border radius applied to
58  * the corners of inner drawable, regardless of whether or not the clipping is enabled, border
59  * radius will be applied to prevent overflowing of the drawable from specified rounded rectangular
60  * area.
61  *
62  * <p>RoundedDrawable can be inflated from XML (supported above API level 24) or constructed
63  * programmatically. To inflate from XML, use {@link android.content.Context#getDrawable(int)}
64  * method.
65  *
66  * <h4>Syntax:</h4>
67  *
68  * <pre>
69  * &lt;?xml version="1.0" encoding="utf-8"?&gt;
70  * &lt;androidx.wear.widget.RoundedDrawable
71  *   xmlns:android="http://schemas.android.com/apk/res/android"
72  *   xmlns:app="http://schemas.android.com/apk/res-auto"
73  *   android:src="drawable"
74  *   app:backgroundColor="color"
75  *   app:radius="dimension"
76  *   app:clipEnabled="boolean" /&gt;</pre>
77  */
78 public class RoundedDrawable extends Drawable {
79 
80     @VisibleForTesting
81     final Paint mPaint;
82     final Paint mBackgroundPaint;
83 
84     private @Nullable Drawable mDrawable;
85     private int mRadius; // Radius applied to corners in pixels
86     private boolean mIsClipEnabled;
87 
88     // Used to avoid creating new Rect objects every time draw() is called
89     private final Rect mTmpBounds = new Rect();
90     private final RectF mTmpBoundsF = new RectF();
91 
RoundedDrawable()92     public RoundedDrawable() {
93         mPaint = new Paint();
94         mPaint.setAntiAlias(true);
95         mBackgroundPaint = new Paint();
96         mBackgroundPaint.setAntiAlias(true);
97         mBackgroundPaint.setColor(Color.TRANSPARENT);
98     }
99 
100     @Override
inflate(@onNull Resources r, @NonNull XmlPullParser parser, @NonNull AttributeSet attrs, Resources.@Nullable Theme theme)101     public void inflate(@NonNull Resources r, @NonNull XmlPullParser parser,
102             @NonNull AttributeSet attrs, Resources.@Nullable Theme theme)
103             throws XmlPullParserException, IOException {
104         super.inflate(r, parser, attrs, theme);
105         TypedArray a = r.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.RoundedDrawable);
106         if (a.hasValue(R.styleable.RoundedDrawable_android_src)) {
107             setDrawable(a.getDrawable(R.styleable.RoundedDrawable_android_src));
108         }
109         setRadius(a.getDimensionPixelSize(R.styleable.RoundedDrawable_radius, 0));
110         setClipEnabled(a.getBoolean(R.styleable.RoundedDrawable_clipEnabled, false));
111         setBackgroundColor(
112                 a.getColor(R.styleable.RoundedDrawable_backgroundColor, Color.TRANSPARENT));
113         a.recycle();
114     }
115 
116     /**
117      * Sets the drawable to be rendered.
118      *
119      * @param drawable {@link Drawable} to be rendered
120      * {@link android.R.attr#src}
121      */
setDrawable(@ullable Drawable drawable)122     public void setDrawable(@Nullable Drawable drawable) {
123         if (Objects.equals(mDrawable, drawable)) {
124             return;
125         }
126         mDrawable = drawable;
127         mPaint.setShader(null); // Clear the shader so it can be reinitialized
128         invalidateSelf();
129     }
130 
131     /**
132      * Returns the drawable that will be rendered.
133      *
134      * @return {@link Drawable} that will be rendered.
135      */
getDrawable()136     public @Nullable Drawable getDrawable() {
137         return mDrawable;
138     }
139 
140     /**
141      * Sets the background color of the rounded drawable.
142      *
143      * @param color an ARGB color
144      * {@link androidx.wear.R.attr#backgroundColor}
145      */
setBackgroundColor(@olorInt int color)146     public void setBackgroundColor(@ColorInt int color) {
147         mBackgroundPaint.setColor(color);
148         invalidateSelf();
149     }
150 
151     /**
152      * Returns the background color.
153      *
154      * @return an ARGB color
155      */
156     @ColorInt
getBackgroundColor()157     public int getBackgroundColor() {
158         return mBackgroundPaint.getColor();
159     }
160 
161     /**
162      * Sets whether the drawable inside should be clipped or resized to fit the rounded bounds. If
163      * the drawable is animated, don't set clipping to {@code true} as clipping on animated
164      * drawables is not supported.
165      *
166      * @param clipEnabled {@code true} if the drawable should be clipped, {@code false} if it
167      *                    should be resized.
168      * {@link androidx.wear.R.attr#clipEnabled}
169      */
setClipEnabled(boolean clipEnabled)170     public void setClipEnabled(boolean clipEnabled) {
171         mIsClipEnabled = clipEnabled;
172         if (!clipEnabled) {
173             mPaint.setShader(null); // Clear the shader so it's garbage collected
174         }
175         invalidateSelf();
176     }
177 
178     /**
179      * Returns whether the drawable inside is clipped or resized to fit the rounded bounds.
180      *
181      * @return {@code true} if the drawable is clipped, {@code false} if it's resized.
182      */
isClipEnabled()183     public boolean isClipEnabled() {
184         return mIsClipEnabled;
185     }
186 
187     @Override
onBoundsChange(Rect bounds)188     protected void onBoundsChange(Rect bounds) {
189         mTmpBounds.right = bounds.width();
190         mTmpBounds.bottom = bounds.height();
191         mTmpBoundsF.right = bounds.width();
192         mTmpBoundsF.bottom = bounds.height();
193         mPaint.setShader(null); // Clear the shader so it can be reinitialized
194     }
195 
196     @Override
draw(@onNull Canvas canvas)197     public void draw(@NonNull Canvas canvas) {
198         Rect bounds = getBounds();
199         if (mDrawable == null || bounds.isEmpty()) {
200             return;
201         }
202         canvas.save();
203         canvas.translate(bounds.left, bounds.top);
204         // mTmpBoundsF is bounds translated to (0,0) and converted to RectF as drawRoundRect
205         // requires.
206         canvas.drawRoundRect(mTmpBoundsF, (float) mRadius, (float) mRadius,
207                 mBackgroundPaint);
208         if (mIsClipEnabled) {
209             // Update the shader if it's not present.
210             if (mPaint.getShader() == null) {
211                 updateBitmapShader();
212             }
213             // Clip to a rounded rectangle
214             canvas.drawRoundRect(mTmpBoundsF, (float) mRadius, (float) mRadius, mPaint);
215         } else {
216             // Scale to fit the rounded rectangle
217             int minEdge = Math.min(bounds.width(), bounds.height());
218             int padding = (int) Math.ceil(
219                     Math.min(mRadius, minEdge / 2) * (1 - 1 / (float) Math.sqrt(2.0)));
220             mTmpBounds.inset(padding, padding);
221             mDrawable.setBounds(mTmpBounds);
222             mDrawable.draw(canvas);
223             mTmpBounds.inset(-padding, -padding);
224         }
225         canvas.restore();
226     }
227 
228     @SuppressWarnings("deprecation")
229     @Override
getOpacity()230     public int getOpacity() {
231         return PixelFormat.TRANSLUCENT;
232     }
233 
234     @Override
setAlpha(int alpha)235     public void setAlpha(int alpha) {
236         mPaint.setAlpha(alpha);
237         mBackgroundPaint.setAlpha(alpha);
238     }
239 
240     @Override
getAlpha()241     public int getAlpha() {
242         return mPaint.getAlpha();
243     }
244 
245     @Override
setColorFilter(@ullable ColorFilter cf)246     public void setColorFilter(@Nullable ColorFilter cf) {
247         mPaint.setColorFilter(cf);
248     }
249 
250     /**
251      * Sets the border radius to be applied when rendering the drawable in pixels.
252      *
253      * @param radius radius in pixels
254      * {@link androidx.wear.R.attr#radius}
255      */
setRadius(int radius)256     public void setRadius(int radius) {
257         mRadius = radius;
258     }
259 
260     /**
261      * Returns the border radius applied when rendering the drawable in pixels.
262      *
263      * @return radius in pixels
264      */
getRadius()265     public int getRadius() {
266         return mRadius;
267     }
268 
269     /**
270      * Updates the shader of the paint. To avoid scaling and creation of a BitmapShader every time,
271      * this method should be called only if the drawable or the bounds has changed.
272      */
updateBitmapShader()273     private void updateBitmapShader() {
274         if (mDrawable == null) {
275             return;
276         }
277         Rect bounds = getBounds();
278         if (!bounds.isEmpty()) {
279             Bitmap bitmap = drawableToBitmap(mDrawable, bounds.width(), bounds.height());
280 
281             Shader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
282             mPaint.setShader(shader);
283         }
284     }
285 
286     /** Converts a drawable to a bitmap of specified width and height. */
drawableToBitmap(Drawable drawable, int width, int height)287     private Bitmap drawableToBitmap(Drawable drawable, int width, int height) {
288         Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
289 
290         Canvas canvas = new Canvas(bitmap);
291         drawable.setBounds(0, 0, width, height);
292         drawable.draw(canvas);
293         return bitmap;
294     }
295 }
296