1 /*
2  * Copyright (C) 2014 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.core.graphics.drawable;
17 
18 import android.content.res.Resources;
19 import android.graphics.Bitmap;
20 import android.graphics.BitmapShader;
21 import android.graphics.Canvas;
22 import android.graphics.ColorFilter;
23 import android.graphics.Matrix;
24 import android.graphics.Paint;
25 import android.graphics.PixelFormat;
26 import android.graphics.Rect;
27 import android.graphics.RectF;
28 import android.graphics.Shader;
29 import android.graphics.drawable.Drawable;
30 import android.util.DisplayMetrics;
31 import android.view.Gravity;
32 
33 import org.jspecify.annotations.NonNull;
34 import org.jspecify.annotations.Nullable;
35 
36 /**
37  * A Drawable that wraps a bitmap and can be drawn with rounded corners. You can create a
38  * RoundedBitmapDrawable from a file path, an input stream, or from a
39  * {@link android.graphics.Bitmap} object.
40  * <p>
41  * Also see the {@link android.graphics.Bitmap} class, which handles the management and
42  * transformation of raw bitmap graphics, and should be used when drawing to a
43  * {@link android.graphics.Canvas}.
44  * </p>
45  */
46 public abstract class RoundedBitmapDrawable extends Drawable {
47     private static final int DEFAULT_PAINT_FLAGS =
48             Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG;
49     final Bitmap mBitmap;
50     private int mTargetDensity = DisplayMetrics.DENSITY_DEFAULT;
51     private int mGravity = Gravity.FILL;
52     private final Paint mPaint = new Paint(DEFAULT_PAINT_FLAGS);
53     private final BitmapShader mBitmapShader;
54     private final Matrix mShaderMatrix = new Matrix();
55     private float mCornerRadius;
56 
57     final Rect mDstRect = new Rect();   // Gravity.apply() sets this
58     private final RectF mDstRectF = new RectF();
59 
60     private boolean mApplyGravity = true;
61     private boolean mIsCircular;
62 
63     // These are scaled to match the target density.
64     private int mBitmapWidth;
65     private int mBitmapHeight;
66 
67     /**
68      * Returns the paint used to render this drawable.
69      */
getPaint()70     public final @NonNull Paint getPaint() {
71         return mPaint;
72     }
73 
74     /**
75      * Returns the bitmap used by this drawable to render. May be null.
76      */
getBitmap()77     public final @Nullable Bitmap getBitmap() {
78         return mBitmap;
79     }
80 
computeBitmapSize()81     private void computeBitmapSize() {
82         mBitmapWidth = mBitmap.getScaledWidth(mTargetDensity);
83         mBitmapHeight = mBitmap.getScaledHeight(mTargetDensity);
84     }
85 
86     /**
87      * Set the density scale at which this drawable will be rendered. This
88      * method assumes the drawable will be rendered at the same density as the
89      * specified canvas.
90      *
91      * @param canvas The Canvas from which the density scale must be obtained.
92      *
93      * @see android.graphics.Bitmap#setDensity(int)
94      * @see android.graphics.Bitmap#getDensity()
95      */
setTargetDensity(@onNull Canvas canvas)96     public void setTargetDensity(@NonNull Canvas canvas) {
97         setTargetDensity(canvas.getDensity());
98     }
99 
100     /**
101      * Set the density scale at which this drawable will be rendered.
102      *
103      * @param metrics The DisplayMetrics indicating the density scale for this drawable.
104      *
105      * @see android.graphics.Bitmap#setDensity(int)
106      * @see android.graphics.Bitmap#getDensity()
107      */
setTargetDensity(@onNull DisplayMetrics metrics)108     public void setTargetDensity(@NonNull DisplayMetrics metrics) {
109         setTargetDensity(metrics.densityDpi);
110     }
111 
112     /**
113      * Set the density at which this drawable will be rendered.
114      *
115      * @param density The density scale for this drawable.
116      *
117      * @see android.graphics.Bitmap#setDensity(int)
118      * @see android.graphics.Bitmap#getDensity()
119      */
setTargetDensity(int density)120     public void setTargetDensity(int density) {
121         if (mTargetDensity != density) {
122             mTargetDensity = density == 0 ? DisplayMetrics.DENSITY_DEFAULT : density;
123             if (mBitmap != null) {
124                 computeBitmapSize();
125             }
126             invalidateSelf();
127         }
128     }
129 
130     /**
131      * Get the gravity used to position/stretch the bitmap within its bounds.
132      *
133      * @return the gravity applied to the bitmap
134      *
135      * @see android.view.Gravity
136      */
getGravity()137     public int getGravity() {
138         return mGravity;
139     }
140 
141     /**
142      * Set the gravity used to position/stretch the bitmap within its bounds.
143      *
144      * @param gravity the gravity
145      *
146      * @see android.view.Gravity
147      */
setGravity(int gravity)148     public void setGravity(int gravity) {
149         if (mGravity != gravity) {
150             mGravity = gravity;
151             mApplyGravity = true;
152             invalidateSelf();
153         }
154     }
155 
156     /**
157      * Enables or disables the mipmap hint for this drawable's bitmap.
158      * See {@link Bitmap#setHasMipMap(boolean)} for more information.
159      *
160      * If the bitmap is null, or the current API version does not support setting a mipmap hint,
161      * calling this method has no effect.
162      *
163      * @param mipMap True if the bitmap should use mipmaps, false otherwise.
164      *
165      * @see #hasMipMap()
166      */
setMipMap(boolean mipMap)167     public void setMipMap(boolean mipMap) {
168         throw new UnsupportedOperationException(); // must be overridden in subclasses
169     }
170 
171     /**
172      * Indicates whether the mipmap hint is enabled on this drawable's bitmap.
173      *
174      * @return True if the mipmap hint is set, false otherwise. If the bitmap
175      *         is null, this method always returns false.
176      *
177      * @see #setMipMap(boolean)
178      */
hasMipMap()179     public boolean hasMipMap() {
180         throw new UnsupportedOperationException(); // must be overridden in subclasses
181     }
182 
183     /**
184      * Enables or disables anti-aliasing for this drawable. Anti-aliasing affects
185      * the edges of the bitmap only so it applies only when the drawable is rotated.
186      *
187      * @param aa True if the bitmap should be anti-aliased, false otherwise.
188      *
189      * @see #hasAntiAlias()
190      */
setAntiAlias(boolean aa)191     public void setAntiAlias(boolean aa) {
192         mPaint.setAntiAlias(aa);
193         invalidateSelf();
194     }
195 
196     /**
197      * Indicates whether anti-aliasing is enabled for this drawable.
198      *
199      * @return True if anti-aliasing is enabled, false otherwise.
200      *
201      * @see #setAntiAlias(boolean)
202      */
hasAntiAlias()203     public boolean hasAntiAlias() {
204         return mPaint.isAntiAlias();
205     }
206 
207     @Override
setFilterBitmap(boolean filter)208     public void setFilterBitmap(boolean filter) {
209         mPaint.setFilterBitmap(filter);
210         invalidateSelf();
211     }
212 
213     @Override
setDither(boolean dither)214     public void setDither(boolean dither) {
215         mPaint.setDither(dither);
216         invalidateSelf();
217     }
218 
gravityCompatApply(int gravity, int bitmapWidth, int bitmapHeight, Rect bounds, Rect outRect)219     void gravityCompatApply(int gravity, int bitmapWidth, int bitmapHeight,
220             Rect bounds, Rect outRect) {
221         throw new UnsupportedOperationException();
222     }
223 
updateDstRect()224     void updateDstRect() {
225         if (mApplyGravity) {
226             if (mIsCircular) {
227                 final int minDimen = Math.min(mBitmapWidth, mBitmapHeight);
228                 gravityCompatApply(mGravity, minDimen, minDimen, getBounds(), mDstRect);
229 
230                 // inset the drawing rectangle to the largest contained square,
231                 // so that a circle will be drawn
232                 final int minDrawDimen = Math.min(mDstRect.width(), mDstRect.height());
233                 final int insetX = Math.max(0, (mDstRect.width() - minDrawDimen) / 2);
234                 final int insetY = Math.max(0, (mDstRect.height() - minDrawDimen) / 2);
235                 mDstRect.inset(insetX, insetY);
236                 mCornerRadius = 0.5f * minDrawDimen;
237             } else {
238                 gravityCompatApply(mGravity, mBitmapWidth, mBitmapHeight, getBounds(), mDstRect);
239             }
240             mDstRectF.set(mDstRect);
241 
242             if (mBitmapShader != null) {
243                 // setup shader matrix
244                 mShaderMatrix.setTranslate(mDstRectF.left,mDstRectF.top);
245                 mShaderMatrix.preScale(
246                         mDstRectF.width() / mBitmap.getWidth(),
247                         mDstRectF.height() / mBitmap.getHeight());
248                 mBitmapShader.setLocalMatrix(mShaderMatrix);
249                 mPaint.setShader(mBitmapShader);
250             }
251 
252             mApplyGravity = false;
253         }
254     }
255 
256     @Override
draw(@onNull Canvas canvas)257     public void draw(@NonNull Canvas canvas) {
258         final Bitmap bitmap = mBitmap;
259         if (bitmap == null) {
260             return;
261         }
262 
263         updateDstRect();
264         if (mPaint.getShader() == null) {
265             canvas.drawBitmap(bitmap, null, mDstRect, mPaint);
266         } else {
267             canvas.drawRoundRect(mDstRectF, mCornerRadius, mCornerRadius, mPaint);
268         }
269     }
270 
271     @Override
setAlpha(int alpha)272     public void setAlpha(int alpha) {
273         final int oldAlpha = mPaint.getAlpha();
274         if (alpha != oldAlpha) {
275             mPaint.setAlpha(alpha);
276             invalidateSelf();
277         }
278     }
279 
280     @Override
getAlpha()281     public int getAlpha() {
282         return mPaint.getAlpha();
283     }
284 
285     @Override
setColorFilter(ColorFilter cf)286     public void setColorFilter(ColorFilter cf) {
287         mPaint.setColorFilter(cf);
288         invalidateSelf();
289     }
290 
291     @Override
getColorFilter()292     public ColorFilter getColorFilter() {
293         return mPaint.getColorFilter();
294     }
295 
296     /**
297      * Sets the image shape to circular.
298      * <p>This overwrites any calls made to {@link #setCornerRadius(float)} so far.</p>
299      */
setCircular(boolean circular)300     public void setCircular(boolean circular) {
301         mIsCircular = circular;
302         mApplyGravity = true;
303         if (circular) {
304             updateCircularCornerRadius();
305             mPaint.setShader(mBitmapShader);
306             invalidateSelf();
307         } else {
308             setCornerRadius(0);
309         }
310     }
311 
updateCircularCornerRadius()312     private void updateCircularCornerRadius() {
313         final int minCircularSize = Math.min(mBitmapHeight, mBitmapWidth);
314         mCornerRadius = minCircularSize / 2;
315     }
316 
317     /**
318      * @return <code>true</code> if the image is circular, else <code>false</code>.
319      */
isCircular()320     public boolean isCircular() {
321         return mIsCircular;
322     }
323 
324     /**
325      * Sets the corner radius to be applied when drawing the bitmap.
326      */
setCornerRadius(float cornerRadius)327     public void setCornerRadius(float cornerRadius) {
328         if (mCornerRadius == cornerRadius) return;
329 
330         mIsCircular = false;
331         if (isGreaterThanZero(cornerRadius)) {
332             mPaint.setShader(mBitmapShader);
333         } else {
334             mPaint.setShader(null);
335         }
336 
337         mCornerRadius = cornerRadius;
338         invalidateSelf();
339     }
340 
341     @Override
onBoundsChange(@onNull Rect bounds)342     protected void onBoundsChange(@NonNull Rect bounds) {
343         super.onBoundsChange(bounds);
344         if (mIsCircular) {
345             updateCircularCornerRadius();
346         }
347         mApplyGravity = true;
348     }
349 
350     /**
351      * @return The corner radius applied when drawing the bitmap.
352      */
getCornerRadius()353     public float getCornerRadius() {
354         return mCornerRadius;
355     }
356 
357     @Override
getIntrinsicWidth()358     public int getIntrinsicWidth() {
359         return mBitmapWidth;
360     }
361 
362     @Override
getIntrinsicHeight()363     public int getIntrinsicHeight() {
364         return mBitmapHeight;
365     }
366 
367     @Override
getOpacity()368     public int getOpacity() {
369         if (mGravity != Gravity.FILL || mIsCircular) {
370             return PixelFormat.TRANSLUCENT;
371         }
372         Bitmap bm = mBitmap;
373         return (bm == null
374                 || bm.hasAlpha()
375                 || mPaint.getAlpha() < 255
376                 || isGreaterThanZero(mCornerRadius))
377                 ? PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE;
378     }
379 
RoundedBitmapDrawable(Resources res, Bitmap bitmap)380     RoundedBitmapDrawable(Resources res, Bitmap bitmap) {
381         if (res != null) {
382             mTargetDensity = res.getDisplayMetrics().densityDpi;
383         }
384 
385         mBitmap = bitmap;
386         if (mBitmap != null) {
387             computeBitmapSize();
388             mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
389         } else {
390             mBitmapWidth = mBitmapHeight = -1;
391             mBitmapShader = null;
392         }
393     }
394 
isGreaterThanZero(float toCompare)395     private static boolean isGreaterThanZero(float toCompare) {
396         return toCompare > 0.05f;
397     }
398 }
399