1 /* 2 * Copyright (C) 2022 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.fuelgauge.batteryusage; 17 18 import static com.android.settings.Utils.formatPercentage; 19 import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.AxisLabelPosition.BETWEEN_TRAPEZOIDS; 20 import static com.android.settingslib.fuelgauge.BatteryStatus.BATTERY_LEVEL_UNKNOWN; 21 22 import static java.lang.Math.abs; 23 import static java.lang.Math.round; 24 import static java.util.Objects.requireNonNull; 25 26 import android.content.Context; 27 import android.content.res.Resources; 28 import android.graphics.Canvas; 29 import android.graphics.Color; 30 import android.graphics.CornerPathEffect; 31 import android.graphics.Paint; 32 import android.graphics.Path; 33 import android.graphics.Rect; 34 import android.graphics.drawable.Drawable; 35 import android.os.Bundle; 36 import android.util.ArraySet; 37 import android.util.AttributeSet; 38 import android.util.Log; 39 import android.view.HapticFeedbackConstants; 40 import android.view.MotionEvent; 41 import android.view.View; 42 import android.view.ViewParent; 43 import android.view.accessibility.AccessibilityEvent; 44 import android.view.accessibility.AccessibilityManager; 45 import android.view.accessibility.AccessibilityNodeInfo; 46 import android.view.accessibility.AccessibilityNodeProvider; 47 import android.widget.TextView; 48 49 import androidx.annotation.NonNull; 50 import androidx.annotation.Nullable; 51 import androidx.annotation.VisibleForTesting; 52 import androidx.appcompat.widget.AppCompatImageView; 53 54 import com.android.settings.R; 55 import com.android.settingslib.Utils; 56 57 import java.util.ArrayList; 58 import java.util.List; 59 import java.util.Locale; 60 import java.util.Set; 61 62 /** A widget component to draw chart graph. */ 63 public class BatteryChartView extends AppCompatImageView implements View.OnClickListener { 64 private static final String TAG = "BatteryChartView"; 65 66 private static final int DIVIDER_COLOR = Color.parseColor("#CDCCC5"); 67 private static final int HORIZONTAL_DIVIDER_COUNT = 5; 68 69 /** A callback listener for selected group index is updated. */ 70 public interface OnSelectListener { 71 /** The callback function for selected group index is updated. */ onSelect(int trapezoidIndex)72 void onSelect(int trapezoidIndex); 73 } 74 75 private final String[] mPercentages = getPercentages(); 76 private final Rect mIndent = new Rect(); 77 private final Rect[] mPercentageBounds = new Rect[] {new Rect(), new Rect(), new Rect()}; 78 private final List<Rect> mAxisLabelsBounds = new ArrayList<>(); 79 private final Set<Integer> mLabelDrawnIndexes = new ArraySet<>(); 80 private final int mLayoutDirection = 81 getContext().getResources().getConfiguration().getLayoutDirection(); 82 83 private BatteryChartViewModel mViewModel; 84 private int mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; 85 private int mDividerWidth; 86 private int mDividerHeight; 87 private float mTrapezoidVOffset; 88 private float mTrapezoidHOffset; 89 private int mTrapezoidColor; 90 private int mTrapezoidSolidColor; 91 private int mTrapezoidHoverColor; 92 private int mDefaultTextColor; 93 private int mTextPadding; 94 private int mTransomIconSize; 95 private int mTransomTop; 96 private int mTransomViewHeight; 97 private int mTransomLineDefaultColor; 98 private int mTransomLineSelectedColor; 99 private float mTransomPadding; 100 private Drawable mTransomIcon; 101 private Paint mTransomLinePaint; 102 private Paint mTransomSelectedSlotPaint; 103 private Paint mDividerPaint; 104 private Paint mTrapezoidPaint; 105 private Paint mTextPaint; 106 private AccessibilityNodeProvider mAccessibilityNodeProvider; 107 private BatteryChartView.OnSelectListener mOnSelectListener; 108 109 @VisibleForTesting TrapezoidSlot[] mTrapezoidSlots; 110 // Records the location to calculate selected index. 111 @VisibleForTesting float mTouchUpEventX = Float.MIN_VALUE; 112 BatteryChartView(Context context)113 public BatteryChartView(Context context) { 114 super(context, null); 115 } 116 BatteryChartView(Context context, AttributeSet attrs)117 public BatteryChartView(Context context, AttributeSet attrs) { 118 super(context, attrs); 119 initializeColors(context); 120 // Registers the click event listener. 121 setOnClickListener(this); 122 setClickable(false); 123 requestLayout(); 124 } 125 126 /** Sets the data model of this view. */ setViewModel(BatteryChartViewModel viewModel)127 public void setViewModel(BatteryChartViewModel viewModel) { 128 if (viewModel == null) { 129 mViewModel = null; 130 invalidate(); 131 return; 132 } 133 134 Log.d( 135 TAG, 136 String.format( 137 "setViewModel(): size: %d, selectedIndex: %d, getHighlightSlotIndex: %d", 138 viewModel.size(), 139 viewModel.selectedIndex(), 140 viewModel.getHighlightSlotIndex())); 141 mViewModel = viewModel; 142 initializeAxisLabelsBounds(); 143 initializeTrapezoidSlots(viewModel.size() - 1); 144 setClickable(hasAnyValidTrapezoid(viewModel)); 145 requestLayout(); 146 } 147 148 /** Sets the callback to monitor the selected group index. */ setOnSelectListener(BatteryChartView.OnSelectListener listener)149 public void setOnSelectListener(BatteryChartView.OnSelectListener listener) { 150 mOnSelectListener = listener; 151 } 152 153 /** Sets the companion {@link TextView} for percentage information. */ setCompanionTextView(TextView textView)154 public void setCompanionTextView(TextView textView) { 155 if (textView != null) { 156 // Pre-draws the view first to load style atttributions into paint. 157 textView.draw(new Canvas()); 158 mTextPaint = textView.getPaint(); 159 mDefaultTextColor = mTextPaint.getColor(); 160 } else { 161 mTextPaint = null; 162 } 163 requestLayout(); 164 } 165 166 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)167 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 168 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 169 // Measures text bounds and updates indent configuration. 170 if (mTextPaint != null) { 171 mTextPaint.setTextAlign(Paint.Align.LEFT); 172 for (int index = 0; index < mPercentages.length; index++) { 173 mTextPaint.getTextBounds( 174 mPercentages[index], 175 0, 176 mPercentages[index].length(), 177 mPercentageBounds[index]); 178 } 179 // Updates the indent configurations. 180 mIndent.top = mPercentageBounds[0].height() + mTransomViewHeight; 181 final int textWidth = mPercentageBounds[0].width() + mTextPadding; 182 if (isRTL()) { 183 mIndent.left = textWidth; 184 } else { 185 mIndent.right = textWidth; 186 } 187 188 if (mViewModel != null) { 189 int maxTop = 0; 190 for (int index = 0; index < mViewModel.size(); index++) { 191 final String text = mViewModel.getText(index); 192 mTextPaint.getTextBounds(text, 0, text.length(), mAxisLabelsBounds.get(index)); 193 maxTop = Math.max(maxTop, -mAxisLabelsBounds.get(index).top); 194 } 195 mIndent.bottom = maxTop + round(mTextPadding * 2f); 196 } 197 Log.d(TAG, "setIndent:" + mPercentageBounds[0]); 198 } else { 199 mIndent.set(0, 0, 0, 0); 200 } 201 } 202 203 @Override draw(Canvas canvas)204 public void draw(Canvas canvas) { 205 super.draw(canvas); 206 // Before mLevels initialized, the count of trapezoids is unknown. Only draws the 207 // horizontal percentages and dividers. 208 drawHorizontalDividers(canvas); 209 if (mViewModel == null) { 210 return; 211 } 212 drawVerticalDividers(canvas); 213 drawTrapezoids(canvas); 214 drawTransomLine(canvas); 215 } 216 217 @Override onTouchEvent(MotionEvent event)218 public boolean onTouchEvent(MotionEvent event) { 219 // Caches the location to calculate selected trapezoid index. 220 final int action = event.getAction(); 221 switch (action) { 222 case MotionEvent.ACTION_UP: 223 mTouchUpEventX = event.getX(); 224 break; 225 case MotionEvent.ACTION_CANCEL: 226 mTouchUpEventX = Float.MIN_VALUE; // reset 227 break; 228 } 229 return super.onTouchEvent(event); 230 } 231 232 @Override onHoverEvent(MotionEvent event)233 public boolean onHoverEvent(MotionEvent event) { 234 final int action = event.getAction(); 235 switch (action) { 236 case MotionEvent.ACTION_HOVER_ENTER: 237 case MotionEvent.ACTION_HOVER_MOVE: 238 final int trapezoidIndex = getTrapezoidIndex(event.getX()); 239 if (mHoveredIndex != trapezoidIndex) { 240 mHoveredIndex = trapezoidIndex; 241 invalidate(); 242 sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); 243 } 244 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been 245 // sent here. 246 return true; 247 case MotionEvent.ACTION_HOVER_EXIT: 248 if (mHoveredIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID) { 249 sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); 250 mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset 251 invalidate(); 252 } 253 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been 254 // sent here. 255 return true; 256 default: 257 return super.onTouchEvent(event); 258 } 259 } 260 261 @Override onHoverChanged(boolean hovered)262 public void onHoverChanged(boolean hovered) { 263 super.onHoverChanged(hovered); 264 if (!hovered) { 265 mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset 266 invalidate(); 267 } 268 } 269 270 @Override onClick(View view)271 public void onClick(View view) { 272 if (mTouchUpEventX == Float.MIN_VALUE) { 273 Log.w(TAG, "invalid motion event for onClick() callback"); 274 return; 275 } 276 onTrapezoidClicked(view, getTrapezoidIndex(mTouchUpEventX)); 277 } 278 279 @Override getAccessibilityNodeProvider()280 public AccessibilityNodeProvider getAccessibilityNodeProvider() { 281 if (mViewModel == null) { 282 return super.getAccessibilityNodeProvider(); 283 } 284 if (mAccessibilityNodeProvider == null) { 285 mAccessibilityNodeProvider = new BatteryChartAccessibilityNodeProvider(); 286 } 287 return mAccessibilityNodeProvider; 288 } 289 onTrapezoidClicked(View view, int index)290 private void onTrapezoidClicked(View view, int index) { 291 // Ignores the click event if the level is zero. 292 if (!isValidToDraw(mViewModel, index)) { 293 return; 294 } 295 if (mOnSelectListener != null) { 296 // Selects all if users click the same trapezoid item two times. 297 mOnSelectListener.onSelect( 298 index == mViewModel.selectedIndex() 299 ? BatteryChartViewModel.SELECTED_INDEX_ALL 300 : index); 301 } 302 view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); 303 } 304 sendAccessibilityEvent(int virtualDescendantId, int eventType)305 private boolean sendAccessibilityEvent(int virtualDescendantId, int eventType) { 306 ViewParent parent = getParent(); 307 if (parent == null || !AccessibilityManager.getInstance(mContext).isEnabled()) { 308 return false; 309 } 310 AccessibilityEvent accessibilityEvent = new AccessibilityEvent(eventType); 311 accessibilityEvent.setSource(this, virtualDescendantId); 312 accessibilityEvent.setEnabled(true); 313 accessibilityEvent.setClassName(getAccessibilityClassName()); 314 accessibilityEvent.setPackageName(getContext().getPackageName()); 315 return parent.requestSendAccessibilityEvent(this, accessibilityEvent); 316 } 317 sendAccessibilityEventForHover(int eventType)318 private void sendAccessibilityEventForHover(int eventType) { 319 if (isTrapezoidIndexValid(mViewModel, mHoveredIndex)) { 320 sendAccessibilityEvent(mHoveredIndex, eventType); 321 } 322 } 323 initializeTrapezoidSlots(int count)324 private void initializeTrapezoidSlots(int count) { 325 mTrapezoidSlots = new TrapezoidSlot[count]; 326 for (int index = 0; index < mTrapezoidSlots.length; index++) { 327 mTrapezoidSlots[index] = new TrapezoidSlot(); 328 } 329 } 330 initializeColors(Context context)331 private void initializeColors(Context context) { 332 setBackgroundColor(Color.TRANSPARENT); 333 mTrapezoidSolidColor = Utils.getColorAccentDefaultColor(context); 334 mTrapezoidColor = Utils.getDisabled(context, mTrapezoidSolidColor); 335 mTrapezoidHoverColor = 336 Utils.getColorAttrDefaultColor( 337 context, com.android.internal.R.attr.materialColorSecondaryContainer); 338 // Initializes the divider line paint. 339 final Resources resources = getContext().getResources(); 340 mDividerWidth = resources.getDimensionPixelSize(R.dimen.chartview_divider_width); 341 mDividerHeight = resources.getDimensionPixelSize(R.dimen.chartview_divider_height); 342 mDividerPaint = new Paint(); 343 mDividerPaint.setAntiAlias(true); 344 mDividerPaint.setColor(DIVIDER_COLOR); 345 mDividerPaint.setStyle(Paint.Style.STROKE); 346 mDividerPaint.setStrokeWidth(mDividerWidth); 347 Log.i(TAG, "mDividerWidth:" + mDividerWidth); 348 Log.i(TAG, "mDividerHeight:" + mDividerHeight); 349 // Initializes the trapezoid paint. 350 mTrapezoidHOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_start); 351 mTrapezoidVOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_bottom); 352 mTrapezoidPaint = new Paint(); 353 mTrapezoidPaint.setAntiAlias(true); 354 mTrapezoidPaint.setColor(mTrapezoidSolidColor); 355 mTrapezoidPaint.setStyle(Paint.Style.FILL); 356 mTrapezoidPaint.setPathEffect( 357 new CornerPathEffect( 358 resources.getDimensionPixelSize(R.dimen.chartview_trapezoid_radius))); 359 // Initializes for drawing text information. 360 mTextPadding = resources.getDimensionPixelSize(R.dimen.chartview_text_padding); 361 // Initializes the padding top for drawing text information. 362 mTransomViewHeight = 363 resources.getDimensionPixelSize(R.dimen.chartview_transom_layout_height); 364 } 365 initializeTransomPaint()366 private void initializeTransomPaint() { 367 if (mTransomLinePaint != null 368 && mTransomSelectedSlotPaint != null 369 && mTransomIcon != null) { 370 return; 371 } 372 // Initializes the transom line paint. 373 final Resources resources = getContext().getResources(); 374 final int transomLineWidth = 375 resources.getDimensionPixelSize(R.dimen.chartview_transom_width); 376 final int transomRadius = resources.getDimensionPixelSize(R.dimen.chartview_transom_radius); 377 mTransomPadding = transomRadius * .5f; 378 mTransomTop = resources.getDimensionPixelSize(R.dimen.chartview_transom_padding_top); 379 mTransomLineDefaultColor = Utils.getDisabled(mContext, DIVIDER_COLOR); 380 mTransomLineSelectedColor = 381 resources.getColor(R.color.color_battery_anomaly_app_warning_selector); 382 final int slotHighlightColor = Utils.getDisabled(mContext, mTransomLineSelectedColor); 383 mTransomIconSize = resources.getDimensionPixelSize(R.dimen.chartview_transom_icon_size); 384 mTransomLinePaint = new Paint(); 385 mTransomLinePaint.setAntiAlias(true); 386 mTransomLinePaint.setStyle(Paint.Style.STROKE); 387 mTransomLinePaint.setStrokeWidth(transomLineWidth); 388 mTransomLinePaint.setStrokeCap(Paint.Cap.ROUND); 389 mTransomLinePaint.setPathEffect(new CornerPathEffect(transomRadius)); 390 mTransomSelectedSlotPaint = new Paint(); 391 mTransomSelectedSlotPaint.setAntiAlias(true); 392 mTransomSelectedSlotPaint.setColor(slotHighlightColor); 393 mTransomSelectedSlotPaint.setStyle(Paint.Style.FILL); 394 // Get the companion icon beside transom line 395 mTransomIcon = getResources().getDrawable(R.drawable.ic_battery_tips_warning_icon); 396 } 397 drawHorizontalDividers(Canvas canvas)398 private void drawHorizontalDividers(Canvas canvas) { 399 final int width = getWidth() - abs(mIndent.width()); 400 final int height = getHeight() - mIndent.top - mIndent.bottom; 401 final float topOffsetY = mIndent.top + mDividerWidth * .5f; 402 final float bottomOffsetY = mIndent.top + (height - mDividerHeight - mDividerWidth * .5f); 403 final float availableSpace = bottomOffsetY - topOffsetY; 404 405 mDividerPaint.setColor(DIVIDER_COLOR); 406 final float dividerOffsetUnit = availableSpace / (float) (HORIZONTAL_DIVIDER_COUNT - 1); 407 408 // Draws 5 divider lines. 409 for (int index = 0; index < HORIZONTAL_DIVIDER_COUNT; index++) { 410 float offsetY = topOffsetY + dividerOffsetUnit * index; 411 canvas.drawLine(mIndent.left, offsetY, mIndent.left + width, offsetY, mDividerPaint); 412 413 // Draws percentage text only for 100% / 50% / 0% 414 if (index % 2 == 0) { 415 drawPercentage(canvas, /* index= */ (index + 1) / 2, offsetY); 416 } 417 } 418 } 419 drawPercentage(Canvas canvas, int index, float offsetY)420 private void drawPercentage(Canvas canvas, int index, float offsetY) { 421 if (mTextPaint != null) { 422 mTextPaint.setTextAlign(isRTL() ? Paint.Align.RIGHT : Paint.Align.LEFT); 423 mTextPaint.setColor(mDefaultTextColor); 424 canvas.drawText( 425 mPercentages[index], 426 isRTL() 427 ? mIndent.left - mTextPadding 428 : getWidth() - mIndent.width() + mTextPadding, 429 offsetY + mPercentageBounds[index].height() * .5f, 430 mTextPaint); 431 } 432 } 433 drawVerticalDividers(Canvas canvas)434 private void drawVerticalDividers(Canvas canvas) { 435 final int width = getWidth() - abs(mIndent.width()); 436 final int dividerCount = mTrapezoidSlots.length + 1; 437 final float dividerSpace = dividerCount * mDividerWidth; 438 final float unitWidth = (width - dividerSpace) / (float) mTrapezoidSlots.length; 439 final float bottomY = getHeight() - mIndent.bottom; 440 final float startY = bottomY - mDividerHeight; 441 final float trapezoidSlotOffset = mTrapezoidHOffset + mDividerWidth * .5f; 442 // Draws the axis label slot information. 443 if (mViewModel != null) { 444 final float baselineY = getHeight() - mTextPadding; 445 Rect[] axisLabelDisplayAreas; 446 switch (mViewModel.axisLabelPosition()) { 447 case CENTER_OF_TRAPEZOIDS: 448 axisLabelDisplayAreas = 449 getAxisLabelDisplayAreas( 450 /* size= */ mViewModel.size() - 1, 451 /* baselineX= */ mIndent.left + mDividerWidth + unitWidth * .5f, 452 /* offsetX= */ mDividerWidth + unitWidth, 453 baselineY, 454 /* shiftFirstAndLast= */ false); 455 break; 456 case BETWEEN_TRAPEZOIDS: 457 default: 458 axisLabelDisplayAreas = 459 getAxisLabelDisplayAreas( 460 /* size= */ mViewModel.size(), 461 /* baselineX= */ mIndent.left + mDividerWidth * .5f, 462 /* offsetX= */ mDividerWidth + unitWidth, 463 baselineY, 464 /* shiftFirstAndLast= */ true); 465 break; 466 } 467 drawAxisLabels(canvas, axisLabelDisplayAreas, baselineY); 468 } 469 // Draws each vertical dividers. 470 float startX = mDividerWidth * .5f + mIndent.left; 471 for (int index = 0; index < dividerCount; index++) { 472 float dividerY = bottomY; 473 if (mViewModel.axisLabelPosition() == BETWEEN_TRAPEZOIDS 474 && mLabelDrawnIndexes.contains(index)) { 475 mDividerPaint.setColor(mTrapezoidSolidColor); 476 dividerY += mDividerHeight / 4f; 477 } else { 478 mDividerPaint.setColor(DIVIDER_COLOR); 479 } 480 canvas.drawLine(startX, startY, startX, dividerY, mDividerPaint); 481 final float nextX = startX + mDividerWidth + unitWidth; 482 // Updates the trapezoid slots for drawing. 483 if (index < mTrapezoidSlots.length) { 484 final int trapezoidIndex = isRTL() ? mTrapezoidSlots.length - index - 1 : index; 485 mTrapezoidSlots[trapezoidIndex].mLeft = round(startX + trapezoidSlotOffset); 486 mTrapezoidSlots[trapezoidIndex].mRight = round(nextX - trapezoidSlotOffset); 487 } 488 startX = nextX; 489 } 490 } 491 492 /** Gets all the axis label texts displaying area positions if they are shown. */ getAxisLabelDisplayAreas( final int size, final float baselineX, final float offsetX, final float baselineY, final boolean shiftFirstAndLast)493 private Rect[] getAxisLabelDisplayAreas( 494 final int size, 495 final float baselineX, 496 final float offsetX, 497 final float baselineY, 498 final boolean shiftFirstAndLast) { 499 final Rect[] result = new Rect[size]; 500 for (int index = 0; index < result.length; index++) { 501 final float width = mAxisLabelsBounds.get(index).width(); 502 float middle = baselineX + index * offsetX; 503 if (shiftFirstAndLast) { 504 if (index == 0) { 505 middle += width * .5f; 506 } 507 if (index == size - 1) { 508 middle -= width * .5f; 509 } 510 } 511 final float left = middle - width * .5f; 512 final float right = left + width; 513 final float top = baselineY + mAxisLabelsBounds.get(index).top; 514 final float bottom = top + mAxisLabelsBounds.get(index).height(); 515 result[index] = new Rect(round(left), round(top), round(right), round(bottom)); 516 } 517 return result; 518 } 519 drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY)520 private void drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY) { 521 final int lastIndex = displayAreas.length - 1; 522 mLabelDrawnIndexes.clear(); 523 // Suppose first and last labels are always able to draw. 524 drawAxisLabelText(canvas, 0, displayAreas[0], baselineY); 525 mLabelDrawnIndexes.add(0); 526 drawAxisLabelText(canvas, lastIndex, displayAreas[lastIndex], baselineY); 527 mLabelDrawnIndexes.add(lastIndex); 528 drawAxisLabelsBetweenStartIndexAndEndIndex(canvas, displayAreas, 0, lastIndex, baselineY); 529 } 530 531 /** 532 * Recursively draws axis labels between the start index and the end index. If the inner number 533 * can be exactly divided into 2 parts, check and draw the middle index label and then 534 * recursively draw the 2 parts. Otherwise, divide into 3 parts. Check and draw the middle two 535 * labels and then recursively draw the 3 parts. If there are any overlaps, skip drawing and go 536 * back to the uplevel of the recursion. 537 */ drawAxisLabelsBetweenStartIndexAndEndIndex( Canvas canvas, final Rect[] displayAreas, final int startIndex, final int endIndex, final float baselineY)538 private void drawAxisLabelsBetweenStartIndexAndEndIndex( 539 Canvas canvas, 540 final Rect[] displayAreas, 541 final int startIndex, 542 final int endIndex, 543 final float baselineY) { 544 if (endIndex - startIndex <= 1) { 545 return; 546 } 547 if ((endIndex - startIndex) % 2 == 0) { 548 int middleIndex = (startIndex + endIndex) / 2; 549 if (hasOverlap(displayAreas, startIndex, middleIndex) 550 || hasOverlap(displayAreas, middleIndex, endIndex)) { 551 return; 552 } 553 drawAxisLabelText(canvas, middleIndex, displayAreas[middleIndex], baselineY); 554 mLabelDrawnIndexes.add(middleIndex); 555 drawAxisLabelsBetweenStartIndexAndEndIndex( 556 canvas, displayAreas, startIndex, middleIndex, baselineY); 557 drawAxisLabelsBetweenStartIndexAndEndIndex( 558 canvas, displayAreas, middleIndex, endIndex, baselineY); 559 } else { 560 int middleIndex1 = startIndex + round((endIndex - startIndex) / 3f); 561 int middleIndex2 = startIndex + round((endIndex - startIndex) * 2 / 3f); 562 if (hasOverlap(displayAreas, startIndex, middleIndex1) 563 || hasOverlap(displayAreas, middleIndex1, middleIndex2) 564 || hasOverlap(displayAreas, middleIndex2, endIndex)) { 565 return; 566 } 567 drawAxisLabelText(canvas, middleIndex1, displayAreas[middleIndex1], baselineY); 568 mLabelDrawnIndexes.add(middleIndex1); 569 drawAxisLabelText(canvas, middleIndex2, displayAreas[middleIndex2], baselineY); 570 mLabelDrawnIndexes.add(middleIndex2); 571 drawAxisLabelsBetweenStartIndexAndEndIndex( 572 canvas, displayAreas, startIndex, middleIndex1, baselineY); 573 drawAxisLabelsBetweenStartIndexAndEndIndex( 574 canvas, displayAreas, middleIndex1, middleIndex2, baselineY); 575 drawAxisLabelsBetweenStartIndexAndEndIndex( 576 canvas, displayAreas, middleIndex2, endIndex, baselineY); 577 } 578 } 579 hasOverlap( final Rect[] displayAreas, final int leftIndex, final int rightIndex)580 private boolean hasOverlap( 581 final Rect[] displayAreas, final int leftIndex, final int rightIndex) { 582 return displayAreas[leftIndex].right + mTextPadding * 2.3f > displayAreas[rightIndex].left; 583 } 584 isRTL()585 private boolean isRTL() { 586 return mLayoutDirection == View.LAYOUT_DIRECTION_RTL; 587 } 588 drawAxisLabelText( Canvas canvas, int index, final Rect displayArea, final float baselineY)589 private void drawAxisLabelText( 590 Canvas canvas, int index, final Rect displayArea, final float baselineY) { 591 mTextPaint.setColor(mTrapezoidSolidColor); 592 mTextPaint.setTextAlign(Paint.Align.CENTER); 593 // Reverse the sort of axis labels for RTL 594 if (isRTL()) { 595 index = 596 mViewModel.axisLabelPosition() == BETWEEN_TRAPEZOIDS 597 ? mViewModel.size() - index - 1 // for hourly 598 : mViewModel.size() - index - 2; // for daily 599 } 600 canvas.drawText(mViewModel.getText(index), displayArea.centerX(), baselineY, mTextPaint); 601 mLabelDrawnIndexes.add(index); 602 } 603 drawTrapezoids(Canvas canvas)604 private void drawTrapezoids(Canvas canvas) { 605 // Ignores invalid trapezoid data. 606 if (mViewModel == null) { 607 return; 608 } 609 final float trapezoidBottom = 610 getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth - mTrapezoidVOffset; 611 final float availableSpace = 612 trapezoidBottom - mDividerWidth * .5f - mIndent.top - mTrapezoidVOffset; 613 final float unitHeight = availableSpace / 100f; 614 // Draws all trapezoid shapes into the canvas. 615 final Path trapezoidPath = new Path(); 616 Path trapezoidCurvePath = null; 617 for (int index = 0; index < mTrapezoidSlots.length; index++) { 618 // Not draws the trapezoid for corner or not initialization cases. 619 if (!isValidToDraw(mViewModel, index)) { 620 continue; 621 } 622 // Configures the trapezoid paint color. 623 final int trapezoidColor = 624 (mViewModel.selectedIndex() == index 625 || mViewModel.selectedIndex() 626 == BatteryChartViewModel.SELECTED_INDEX_ALL) 627 ? mTrapezoidSolidColor 628 : mTrapezoidColor; 629 final boolean isHoverState = 630 mHoveredIndex == index && isValidToDraw(mViewModel, mHoveredIndex); 631 mTrapezoidPaint.setColor(isHoverState ? mTrapezoidHoverColor : trapezoidColor); 632 633 float leftTop = 634 round( 635 trapezoidBottom 636 - requireNonNull(mViewModel.getLevel(index)) * unitHeight); 637 float rightTop = 638 round( 639 trapezoidBottom 640 - requireNonNull(mViewModel.getLevel(index + 1)) * unitHeight); 641 // Mirror the shape of the trapezoid for RTL 642 if (isRTL()) { 643 float temp = leftTop; 644 leftTop = rightTop; 645 rightTop = temp; 646 } 647 trapezoidPath.reset(); 648 trapezoidPath.moveTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); 649 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); 650 trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, rightTop); 651 trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, trapezoidBottom); 652 // A tricky way to make the trapezoid shape drawing the rounded corner. 653 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); 654 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); 655 // Draws the trapezoid shape into canvas. 656 canvas.drawPath(trapezoidPath, mTrapezoidPaint); 657 } 658 } 659 isHighlightSlotValid()660 private boolean isHighlightSlotValid() { 661 return mViewModel != null 662 && mViewModel.getHighlightSlotIndex() 663 != BatteryChartViewModel.SELECTED_INDEX_INVALID; 664 } 665 drawTransomLine(Canvas canvas)666 private void drawTransomLine(Canvas canvas) { 667 if (!isHighlightSlotValid()) { 668 return; 669 } 670 initializeTransomPaint(); 671 // Draw the whole transom line and a warning icon 672 mTransomLinePaint.setColor(mTransomLineDefaultColor); 673 final int width = getWidth() - abs(mIndent.width()); 674 final float transomOffset = mTrapezoidHOffset + mDividerWidth * .5f + mTransomPadding; 675 final float trapezoidBottom = 676 getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth - mTrapezoidVOffset; 677 canvas.drawLine( 678 mIndent.left + transomOffset, 679 mTransomTop, 680 mIndent.left + width - transomOffset, 681 mTransomTop, 682 mTransomLinePaint); 683 drawTransomIcon(canvas); 684 // Draw selected segment of transom line and a highlight slot 685 mTransomLinePaint.setColor(mTransomLineSelectedColor); 686 final int index = mViewModel.getHighlightSlotIndex(); 687 final float startX = mTrapezoidSlots[index].mLeft; 688 final float endX = mTrapezoidSlots[index].mRight; 689 canvas.drawLine( 690 startX + mTransomPadding, 691 mTransomTop, 692 endX - mTransomPadding, 693 mTransomTop, 694 mTransomLinePaint); 695 canvas.drawRect(startX, mTransomTop, endX, trapezoidBottom, mTransomSelectedSlotPaint); 696 } 697 drawTransomIcon(Canvas canvas)698 private void drawTransomIcon(Canvas canvas) { 699 if (mTransomIcon == null) { 700 return; 701 } 702 final int left = 703 isRTL() 704 ? mIndent.left - mTextPadding - mTransomIconSize 705 : getWidth() - abs(mIndent.width()) + mTextPadding; 706 mTransomIcon.setBounds( 707 left, 708 mTransomTop - mTransomIconSize / 2, 709 left + mTransomIconSize, 710 mTransomTop + mTransomIconSize / 2); 711 mTransomIcon.draw(canvas); 712 } 713 714 // Searches the corresponding trapezoid index from x location. getTrapezoidIndex(float x)715 private int getTrapezoidIndex(float x) { 716 if (mTrapezoidSlots == null) { 717 return BatteryChartViewModel.SELECTED_INDEX_INVALID; 718 } 719 for (int index = 0; index < mTrapezoidSlots.length; index++) { 720 final TrapezoidSlot slot = mTrapezoidSlots[index]; 721 if (x >= slot.mLeft - mTrapezoidHOffset && x <= slot.mRight + mTrapezoidHOffset) { 722 return index; 723 } 724 } 725 return BatteryChartViewModel.SELECTED_INDEX_INVALID; 726 } 727 initializeAxisLabelsBounds()728 private void initializeAxisLabelsBounds() { 729 mAxisLabelsBounds.clear(); 730 for (int i = 0; i < mViewModel.size(); i++) { 731 mAxisLabelsBounds.add(new Rect()); 732 } 733 } 734 isTrapezoidValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)735 private static boolean isTrapezoidValid( 736 @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) { 737 return viewModel.getLevel(trapezoidIndex) != BATTERY_LEVEL_UNKNOWN 738 && viewModel.getLevel(trapezoidIndex + 1) != BATTERY_LEVEL_UNKNOWN; 739 } 740 isTrapezoidIndexValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)741 private static boolean isTrapezoidIndexValid( 742 @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) { 743 return viewModel != null && trapezoidIndex >= 0 && trapezoidIndex < viewModel.size() - 1; 744 } 745 isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex)746 private static boolean isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex) { 747 return isTrapezoidIndexValid(viewModel, trapezoidIndex) 748 && isTrapezoidValid(viewModel, trapezoidIndex); 749 } 750 hasAnyValidTrapezoid(@onNull BatteryChartViewModel viewModel)751 private static boolean hasAnyValidTrapezoid(@NonNull BatteryChartViewModel viewModel) { 752 // Sets the chart is clickable if there is at least one valid item in it. 753 for (int trapezoidIndex = 0; trapezoidIndex < viewModel.size() - 1; trapezoidIndex++) { 754 if (isTrapezoidValid(viewModel, trapezoidIndex)) { 755 return true; 756 } 757 } 758 return false; 759 } 760 getPercentages()761 private static String[] getPercentages() { 762 return new String[] { 763 formatPercentage(/* percentage= */ 100, /* round= */ true), 764 formatPercentage(/* percentage= */ 50, /* round= */ true), 765 formatPercentage(/* percentage= */ 0, /* round= */ true) 766 }; 767 } 768 769 private class BatteryChartAccessibilityNodeProvider extends AccessibilityNodeProvider { 770 private static final int UNDEFINED = Integer.MIN_VALUE; 771 772 private int mAccessibilityFocusNodeViewId = UNDEFINED; 773 774 @Override createAccessibilityNodeInfo(int virtualViewId)775 public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { 776 if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) { 777 final AccessibilityNodeInfo hostInfo = 778 new AccessibilityNodeInfo(BatteryChartView.this); 779 for (int index = 0; index < mViewModel.size() - 1; index++) { 780 hostInfo.addChild(BatteryChartView.this, index); 781 } 782 return hostInfo; 783 } 784 final int index = virtualViewId; 785 if (!isTrapezoidIndexValid(mViewModel, index)) { 786 Log.w(TAG, "Invalid virtual view id:" + index); 787 return null; 788 } 789 final AccessibilityNodeInfo childInfo = 790 new AccessibilityNodeInfo(BatteryChartView.this, index); 791 final String slotTimeInfo = mViewModel.getContentDescription(index); 792 final String batteryLevelInfo = mViewModel.getSlotBatteryLevelText(index); 793 onInitializeAccessibilityNodeInfo(childInfo); 794 childInfo.setClickable(isValidToDraw(mViewModel, index)); 795 childInfo.setText(slotTimeInfo); 796 childInfo.setContentDescription( 797 mContext.getString( 798 R.string.battery_usage_time_info_and_battery_level, 799 slotTimeInfo, 800 batteryLevelInfo)); 801 childInfo.setAccessibilityFocused(virtualViewId == mAccessibilityFocusNodeViewId); 802 803 final Rect bounds = new Rect(); 804 getBoundsOnScreen(bounds, true); 805 final int hostLeft = bounds.left; 806 bounds.left = round(hostLeft + mTrapezoidSlots[index].mLeft); 807 bounds.right = round(hostLeft + mTrapezoidSlots[index].mRight); 808 childInfo.setBoundsInScreen(bounds); 809 return childInfo; 810 } 811 812 @Override performAction(int virtualViewId, int action, @Nullable Bundle arguments)813 public boolean performAction(int virtualViewId, int action, @Nullable Bundle arguments) { 814 if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) { 815 return performAccessibilityAction(action, arguments); 816 } 817 switch (action) { 818 case AccessibilityNodeInfo.ACTION_CLICK: 819 onTrapezoidClicked(BatteryChartView.this, virtualViewId); 820 return true; 821 822 case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: 823 mAccessibilityFocusNodeViewId = virtualViewId; 824 return sendAccessibilityEvent( 825 virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); 826 827 case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: 828 if (mAccessibilityFocusNodeViewId == virtualViewId) { 829 mAccessibilityFocusNodeViewId = UNDEFINED; 830 } 831 return sendAccessibilityEvent( 832 virtualViewId, 833 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); 834 835 default: 836 return performAccessibilityAction(action, arguments); 837 } 838 } 839 } 840 841 // A container class for each trapezoid left and right location. 842 @VisibleForTesting 843 static final class TrapezoidSlot { 844 public float mLeft; 845 public float mRight; 846 847 @Override toString()848 public String toString() { 849 return String.format(Locale.US, "TrapezoidSlot[%f,%f]", mLeft, mRight); 850 } 851 } 852 } 853