1 /* 2 * Copyright (C) 2019 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 com.android.car.developeroptions.widget; 17 18 import android.content.Context; 19 import android.content.res.Resources; 20 import android.content.res.TypedArray; 21 import android.graphics.Canvas; 22 import android.graphics.ColorFilter; 23 import android.graphics.Paint; 24 import android.graphics.PorterDuff; 25 import android.graphics.PorterDuffColorFilter; 26 import android.graphics.Typeface; 27 import android.icu.text.DecimalFormatSymbols; 28 import android.text.Layout; 29 import android.text.Spannable; 30 import android.text.SpannableString; 31 import android.text.Spanned; 32 import android.text.StaticLayout; 33 import android.text.TextPaint; 34 import android.text.TextUtils; 35 import android.text.style.RelativeSizeSpan; 36 import android.util.AttributeSet; 37 import android.view.View; 38 39 import androidx.annotation.ColorRes; 40 import androidx.annotation.VisibleForTesting; 41 42 import com.android.car.developeroptions.R; 43 import com.android.car.developeroptions.Utils; 44 45 import java.util.Locale; 46 47 /** 48 * DonutView represents a donut graph. It visualizes a certain percentage of fullness with a 49 * corresponding label with the fullness on the inside (i.e. "50%" inside of the donut). 50 */ 51 public class DonutView extends View { 52 private static final int TOP = -90; 53 // From manual testing, this is the longest we can go without visual errors. 54 private static final int LINE_CHARACTER_LIMIT = 10; 55 private float mStrokeWidth; 56 private double mPercent; 57 private Paint mBackgroundCircle; 58 private Paint mFilledArc; 59 private TextPaint mTextPaint; 60 private TextPaint mBigNumberPaint; 61 private String mPercentString; 62 private String mFullString; 63 private boolean mShowPercentString = true; 64 private int mMeterBackgroundColor; 65 private int mMeterConsumedColor; 66 DonutView(Context context)67 public DonutView(Context context) { 68 super(context); 69 } 70 DonutView(Context context, AttributeSet attrs)71 public DonutView(Context context, AttributeSet attrs) { 72 super(context, attrs); 73 mMeterBackgroundColor = context.getColor(R.color.meter_background_color); 74 mMeterConsumedColor = Utils.getColorStateListDefaultColor(mContext, 75 R.color.meter_consumed_color); 76 boolean applyColorAccent = true; 77 Resources resources = context.getResources(); 78 mStrokeWidth = resources.getDimension(R.dimen.storage_donut_thickness); 79 80 if (attrs != null) { 81 TypedArray styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.DonutView); 82 mMeterBackgroundColor = styledAttrs.getColor(R.styleable.DonutView_meterBackgroundColor, 83 mMeterBackgroundColor); 84 mMeterConsumedColor = styledAttrs.getColor(R.styleable.DonutView_meterConsumedColor, 85 mMeterConsumedColor); 86 applyColorAccent = styledAttrs.getBoolean(R.styleable.DonutView_applyColorAccent, 87 true); 88 mShowPercentString = styledAttrs.getBoolean(R.styleable.DonutView_showPercentString, 89 true); 90 mStrokeWidth = styledAttrs.getDimensionPixelSize(R.styleable.DonutView_thickness, 91 (int) mStrokeWidth); 92 styledAttrs.recycle(); 93 } 94 95 mBackgroundCircle = new Paint(); 96 mBackgroundCircle.setAntiAlias(true); 97 mBackgroundCircle.setStrokeCap(Paint.Cap.BUTT); 98 mBackgroundCircle.setStyle(Paint.Style.STROKE); 99 mBackgroundCircle.setStrokeWidth(mStrokeWidth); 100 mBackgroundCircle.setColor(mMeterBackgroundColor); 101 102 mFilledArc = new Paint(); 103 mFilledArc.setAntiAlias(true); 104 mFilledArc.setStrokeCap(Paint.Cap.BUTT); 105 mFilledArc.setStyle(Paint.Style.STROKE); 106 mFilledArc.setStrokeWidth(mStrokeWidth); 107 mFilledArc.setColor(mMeterConsumedColor); 108 109 if (applyColorAccent) { 110 final ColorFilter mAccentColorFilter = 111 new PorterDuffColorFilter( 112 Utils.getColorAttrDefaultColor(context, android.R.attr.colorAccent), 113 PorterDuff.Mode.SRC_IN); 114 mBackgroundCircle.setColorFilter(mAccentColorFilter); 115 mFilledArc.setColorFilter(mAccentColorFilter); 116 } 117 118 final Locale locale = resources.getConfiguration().locale; 119 final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale); 120 final int bidiFlags = (layoutDirection == LAYOUT_DIRECTION_LTR) 121 ? Paint.BIDI_LTR 122 : Paint.BIDI_RTL; 123 124 mTextPaint = new TextPaint(); 125 mTextPaint.setColor(Utils.getColorAccentDefaultColor(getContext())); 126 mTextPaint.setAntiAlias(true); 127 mTextPaint.setTextSize( 128 resources.getDimension(R.dimen.storage_donut_view_label_text_size)); 129 mTextPaint.setTextAlign(Paint.Align.CENTER); 130 mTextPaint.setBidiFlags(bidiFlags); 131 132 mBigNumberPaint = new TextPaint(); 133 mBigNumberPaint.setColor(Utils.getColorAccentDefaultColor(getContext())); 134 mBigNumberPaint.setAntiAlias(true); 135 mBigNumberPaint.setTextSize( 136 resources.getDimension(R.dimen.storage_donut_view_percent_text_size)); 137 mBigNumberPaint.setTypeface(Typeface.create( 138 context.getString(com.android.internal.R.string.config_headlineFontFamily), 139 Typeface.NORMAL)); 140 mBigNumberPaint.setBidiFlags(bidiFlags); 141 } 142 143 @Override onDraw(Canvas canvas)144 protected void onDraw(Canvas canvas) { 145 super.onDraw(canvas); 146 drawDonut(canvas); 147 if (mShowPercentString) { 148 drawInnerText(canvas); 149 } 150 } 151 drawDonut(Canvas canvas)152 private void drawDonut(Canvas canvas) { 153 canvas.drawArc( 154 0 + mStrokeWidth, 155 0 + mStrokeWidth, 156 getWidth() - mStrokeWidth, 157 getHeight() - mStrokeWidth, 158 TOP, 159 360, 160 false, 161 mBackgroundCircle); 162 163 canvas.drawArc( 164 0 + mStrokeWidth, 165 0 + mStrokeWidth, 166 getWidth() - mStrokeWidth, 167 getHeight() - mStrokeWidth, 168 TOP, 169 (360 * (float) mPercent), 170 false, 171 mFilledArc); 172 } 173 drawInnerText(Canvas canvas)174 private void drawInnerText(Canvas canvas) { 175 final float centerX = getWidth() / 2; 176 final float centerY = getHeight() / 2; 177 final float totalHeight = getTextHeight(mTextPaint) + getTextHeight(mBigNumberPaint); 178 final float startY = centerY + totalHeight / 2; 179 // Support from Android P 180 final String localizedPercentSign = new DecimalFormatSymbols().getPercentString(); 181 182 // The first line y-coordinates start at (total height - all TextPaint height) / 2 183 canvas.save(); 184 final Spannable percentStringSpan = 185 getPercentageStringSpannable(getResources(), mPercentString, localizedPercentSign); 186 final StaticLayout percentStringLayout = new StaticLayout(percentStringSpan, 187 mBigNumberPaint, getWidth(), Layout.Alignment.ALIGN_CENTER, 1, 0, false); 188 canvas.translate(0, (getHeight() - totalHeight) / 2); 189 percentStringLayout.draw(canvas); 190 canvas.restore(); 191 192 // The second line starts at the bottom + room for the descender. 193 canvas.drawText(mFullString, centerX, startY - mTextPaint.descent(), mTextPaint); 194 } 195 196 /** 197 * Set a percentage full to have the donut graph. 198 */ setPercentage(double percent)199 public void setPercentage(double percent) { 200 mPercent = percent; 201 mPercentString = Utils.formatPercentage(mPercent); 202 mFullString = getContext().getString(R.string.storage_percent_full); 203 if (mFullString.length() > LINE_CHARACTER_LIMIT) { 204 mTextPaint.setTextSize( 205 getContext() 206 .getResources() 207 .getDimension( 208 R.dimen.storage_donut_view_shrunken_label_text_size)); 209 } 210 setContentDescription(getContext().getString( 211 R.string.join_two_unrelated_items, mPercentString, mFullString)); 212 invalidate(); 213 } 214 215 @ColorRes getMeterBackgroundColor()216 public int getMeterBackgroundColor() { 217 return mMeterBackgroundColor; 218 } 219 setMeterBackgroundColor(@olorRes int meterBackgroundColor)220 public void setMeterBackgroundColor(@ColorRes int meterBackgroundColor) { 221 mMeterBackgroundColor = meterBackgroundColor; 222 mBackgroundCircle.setColor(meterBackgroundColor); 223 invalidate(); 224 } 225 226 @ColorRes getMeterConsumedColor()227 public int getMeterConsumedColor() { 228 return mMeterConsumedColor; 229 } 230 setMeterConsumedColor(@olorRes int meterConsumedColor)231 public void setMeterConsumedColor(@ColorRes int meterConsumedColor) { 232 mMeterConsumedColor = meterConsumedColor; 233 mFilledArc.setColor(meterConsumedColor); 234 invalidate(); 235 } 236 237 @VisibleForTesting getPercentageStringSpannable( Resources resources, String percentString, String percentageSignString)238 static Spannable getPercentageStringSpannable( 239 Resources resources, String percentString, String percentageSignString) { 240 final float fontProportion = 241 resources.getDimension(R.dimen.storage_donut_view_percent_sign_size) 242 / resources.getDimension(R.dimen.storage_donut_view_percent_text_size); 243 final Spannable percentStringSpan = new SpannableString(percentString); 244 int startIndex = percentString.indexOf(percentageSignString); 245 int endIndex = startIndex + percentageSignString.length(); 246 247 // Fallback to no small string if we can't find the percentage sign. 248 if (startIndex < 0) { 249 startIndex = 0; 250 endIndex = percentString.length(); 251 } 252 253 percentStringSpan.setSpan( 254 new RelativeSizeSpan(fontProportion), 255 startIndex, 256 endIndex, 257 Spanned.SPAN_EXCLUSIVE_INCLUSIVE); 258 return percentStringSpan; 259 } 260 getTextHeight(TextPaint paint)261 private float getTextHeight(TextPaint paint) { 262 // Technically, this should be the cap height, but I can live with the descent - ascent. 263 return paint.descent() - paint.ascent(); 264 } 265 } 266