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 * <?xml version="1.0" encoding="utf-8"?> 70 * <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" /></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