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 com.android.settings.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.settings.R;
43 import com.android.settings.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