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