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 20 import static java.lang.Math.round; 21 import static java.util.Objects.requireNonNull; 22 23 import android.content.Context; 24 import android.content.res.Resources; 25 import android.graphics.Canvas; 26 import android.graphics.Color; 27 import android.graphics.CornerPathEffect; 28 import android.graphics.Paint; 29 import android.graphics.Path; 30 import android.graphics.Rect; 31 import android.os.Bundle; 32 import android.util.AttributeSet; 33 import android.util.Log; 34 import android.view.HapticFeedbackConstants; 35 import android.view.MotionEvent; 36 import android.view.View; 37 import android.view.ViewParent; 38 import android.view.accessibility.AccessibilityEvent; 39 import android.view.accessibility.AccessibilityManager; 40 import android.view.accessibility.AccessibilityNodeInfo; 41 import android.view.accessibility.AccessibilityNodeProvider; 42 import android.widget.TextView; 43 44 import androidx.annotation.NonNull; 45 import androidx.annotation.Nullable; 46 import androidx.annotation.VisibleForTesting; 47 import androidx.appcompat.widget.AppCompatImageView; 48 49 import com.android.settings.R; 50 import com.android.settingslib.Utils; 51 52 import java.util.ArrayList; 53 import java.util.List; 54 import java.util.Locale; 55 56 /** A widget component to draw chart graph. */ 57 public class BatteryChartView extends AppCompatImageView implements View.OnClickListener { 58 private static final String TAG = "BatteryChartView"; 59 60 private static final int DIVIDER_COLOR = Color.parseColor("#CDCCC5"); 61 private static final long UPDATE_STATE_DELAYED_TIME = 500L; 62 63 /** A callback listener for selected group index is updated. */ 64 public interface OnSelectListener { 65 /** The callback function for selected group index is updated. */ onSelect(int trapezoidIndex)66 void onSelect(int trapezoidIndex); 67 } 68 69 private final String[] mPercentages = getPercentages(); 70 private final Rect mIndent = new Rect(); 71 private final Rect[] mPercentageBounds = new Rect[]{new Rect(), new Rect(), new Rect()}; 72 private final List<Rect> mAxisLabelsBounds = new ArrayList<>(); 73 74 private BatteryChartViewModel mViewModel; 75 private int mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; 76 private int mDividerWidth; 77 private int mDividerHeight; 78 private float mTrapezoidVOffset; 79 private float mTrapezoidHOffset; 80 private int mTrapezoidColor; 81 private int mTrapezoidSolidColor; 82 private int mTrapezoidHoverColor; 83 private int mTextPadding; 84 private Paint mDividerPaint; 85 private Paint mTrapezoidPaint; 86 private Paint mTextPaint; 87 private AccessibilityNodeProvider mAccessibilityNodeProvider; 88 private BatteryChartView.OnSelectListener mOnSelectListener; 89 90 @VisibleForTesting 91 TrapezoidSlot[] mTrapezoidSlots; 92 // Records the location to calculate selected index. 93 @VisibleForTesting 94 float mTouchUpEventX = Float.MIN_VALUE; 95 BatteryChartView(Context context)96 public BatteryChartView(Context context) { 97 super(context, null); 98 } 99 BatteryChartView(Context context, AttributeSet attrs)100 public BatteryChartView(Context context, AttributeSet attrs) { 101 super(context, attrs); 102 initializeColors(context); 103 // Registers the click event listener. 104 setOnClickListener(this); 105 setClickable(false); 106 requestLayout(); 107 } 108 109 /** Sets the data model of this view. */ setViewModel(BatteryChartViewModel viewModel)110 public void setViewModel(BatteryChartViewModel viewModel) { 111 if (viewModel == null) { 112 mViewModel = null; 113 invalidate(); 114 return; 115 } 116 117 Log.d(TAG, String.format("setViewModel(): size: %d, selectedIndex: %d.", 118 viewModel.size(), viewModel.selectedIndex())); 119 mViewModel = viewModel; 120 initializeAxisLabelsBounds(); 121 initializeTrapezoidSlots(viewModel.size() - 1); 122 setClickable(hasAnyValidTrapezoid(viewModel)); 123 requestLayout(); 124 } 125 126 /** Sets the callback to monitor the selected group index. */ setOnSelectListener(BatteryChartView.OnSelectListener listener)127 public void setOnSelectListener(BatteryChartView.OnSelectListener listener) { 128 mOnSelectListener = listener; 129 } 130 131 /** Sets the companion {@link TextView} for percentage information. */ setCompanionTextView(TextView textView)132 public void setCompanionTextView(TextView textView) { 133 if (textView != null) { 134 // Pre-draws the view first to load style atttributions into paint. 135 textView.draw(new Canvas()); 136 mTextPaint = textView.getPaint(); 137 } else { 138 mTextPaint = null; 139 } 140 requestLayout(); 141 } 142 143 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)144 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 145 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 146 // Measures text bounds and updates indent configuration. 147 if (mTextPaint != null) { 148 mTextPaint.setTextAlign(Paint.Align.LEFT); 149 for (int index = 0; index < mPercentages.length; index++) { 150 mTextPaint.getTextBounds( 151 mPercentages[index], 0, mPercentages[index].length(), 152 mPercentageBounds[index]); 153 } 154 // Updates the indent configurations. 155 mIndent.top = mPercentageBounds[0].height(); 156 mIndent.right = mPercentageBounds[0].width() + mTextPadding; 157 158 if (mViewModel != null) { 159 int maxTop = 0; 160 for (int index = 0; index < mViewModel.size(); index++) { 161 final String text = mViewModel.getText(index); 162 mTextPaint.getTextBounds(text, 0, text.length(), mAxisLabelsBounds.get(index)); 163 maxTop = Math.max(maxTop, -mAxisLabelsBounds.get(index).top); 164 } 165 mIndent.bottom = maxTop + round(mTextPadding * 2f); 166 } 167 Log.d(TAG, "setIndent:" + mPercentageBounds[0]); 168 } else { 169 mIndent.set(0, 0, 0, 0); 170 } 171 } 172 173 @Override draw(Canvas canvas)174 public void draw(Canvas canvas) { 175 super.draw(canvas); 176 // Before mLevels initialized, the count of trapezoids is unknown. Only draws the 177 // horizontal percentages and dividers. 178 drawHorizontalDividers(canvas); 179 if (mViewModel == null) { 180 return; 181 } 182 drawVerticalDividers(canvas); 183 drawTrapezoids(canvas); 184 } 185 186 @Override onTouchEvent(MotionEvent event)187 public boolean onTouchEvent(MotionEvent event) { 188 // Caches the location to calculate selected trapezoid index. 189 final int action = event.getAction(); 190 switch (action) { 191 case MotionEvent.ACTION_UP: 192 mTouchUpEventX = event.getX(); 193 break; 194 case MotionEvent.ACTION_CANCEL: 195 mTouchUpEventX = Float.MIN_VALUE; // reset 196 break; 197 } 198 return super.onTouchEvent(event); 199 } 200 201 @Override onHoverEvent(MotionEvent event)202 public boolean onHoverEvent(MotionEvent event) { 203 final int action = event.getAction(); 204 switch (action) { 205 case MotionEvent.ACTION_HOVER_ENTER: 206 case MotionEvent.ACTION_HOVER_MOVE: 207 final int trapezoidIndex = getTrapezoidIndex(event.getX()); 208 if (mHoveredIndex != trapezoidIndex) { 209 mHoveredIndex = trapezoidIndex; 210 invalidate(); 211 sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); 212 } 213 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been 214 // sent here. 215 return true; 216 case MotionEvent.ACTION_HOVER_EXIT: 217 if (mHoveredIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID) { 218 sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); 219 mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset 220 invalidate(); 221 } 222 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been 223 // sent here. 224 return true; 225 default: 226 return super.onTouchEvent(event); 227 } 228 } 229 230 @Override onHoverChanged(boolean hovered)231 public void onHoverChanged(boolean hovered) { 232 super.onHoverChanged(hovered); 233 if (!hovered) { 234 mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset 235 invalidate(); 236 } 237 } 238 239 @Override onClick(View view)240 public void onClick(View view) { 241 if (mTouchUpEventX == Float.MIN_VALUE) { 242 Log.w(TAG, "invalid motion event for onClick() callback"); 243 return; 244 } 245 onTrapezoidClicked(view, getTrapezoidIndex(mTouchUpEventX)); 246 } 247 248 @Override getAccessibilityNodeProvider()249 public AccessibilityNodeProvider getAccessibilityNodeProvider() { 250 if (mViewModel == null) { 251 return super.getAccessibilityNodeProvider(); 252 } 253 if (mAccessibilityNodeProvider == null) { 254 mAccessibilityNodeProvider = new BatteryChartAccessibilityNodeProvider(); 255 } 256 return mAccessibilityNodeProvider; 257 } 258 onTrapezoidClicked(View view, int index)259 private void onTrapezoidClicked(View view, int index) { 260 // Ignores the click event if the level is zero. 261 if (!isValidToDraw(mViewModel, index)) { 262 return; 263 } 264 if (mOnSelectListener != null) { 265 // Selects all if users click the same trapezoid item two times. 266 mOnSelectListener.onSelect( 267 index == mViewModel.selectedIndex() 268 ? BatteryChartViewModel.SELECTED_INDEX_ALL : index); 269 } 270 view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); 271 } 272 sendAccessibilityEvent(int virtualDescendantId, int eventType)273 private boolean sendAccessibilityEvent(int virtualDescendantId, int eventType) { 274 ViewParent parent = getParent(); 275 if (parent == null || !AccessibilityManager.getInstance(mContext).isEnabled()) { 276 return false; 277 } 278 AccessibilityEvent accessibilityEvent = new AccessibilityEvent(eventType); 279 accessibilityEvent.setSource(this, virtualDescendantId); 280 accessibilityEvent.setEnabled(true); 281 accessibilityEvent.setClassName(getAccessibilityClassName()); 282 accessibilityEvent.setPackageName(getContext().getPackageName()); 283 return parent.requestSendAccessibilityEvent(this, accessibilityEvent); 284 } 285 sendAccessibilityEventForHover(int eventType)286 private void sendAccessibilityEventForHover(int eventType) { 287 if (isTrapezoidIndexValid(mViewModel, mHoveredIndex)) { 288 sendAccessibilityEvent(mHoveredIndex, eventType); 289 } 290 } 291 initializeTrapezoidSlots(int count)292 private void initializeTrapezoidSlots(int count) { 293 mTrapezoidSlots = new TrapezoidSlot[count]; 294 for (int index = 0; index < mTrapezoidSlots.length; index++) { 295 mTrapezoidSlots[index] = new TrapezoidSlot(); 296 } 297 } 298 initializeColors(Context context)299 private void initializeColors(Context context) { 300 setBackgroundColor(Color.TRANSPARENT); 301 mTrapezoidSolidColor = Utils.getColorAccentDefaultColor(context); 302 mTrapezoidColor = Utils.getDisabled(context, mTrapezoidSolidColor); 303 mTrapezoidHoverColor = Utils.getColorAttrDefaultColor(context, 304 com.android.internal.R.attr.colorAccentSecondaryVariant); 305 // Initializes the divider line paint. 306 final Resources resources = getContext().getResources(); 307 mDividerWidth = resources.getDimensionPixelSize(R.dimen.chartview_divider_width); 308 mDividerHeight = resources.getDimensionPixelSize(R.dimen.chartview_divider_height); 309 mDividerPaint = new Paint(); 310 mDividerPaint.setAntiAlias(true); 311 mDividerPaint.setColor(DIVIDER_COLOR); 312 mDividerPaint.setStyle(Paint.Style.STROKE); 313 mDividerPaint.setStrokeWidth(mDividerWidth); 314 Log.i(TAG, "mDividerWidth:" + mDividerWidth); 315 Log.i(TAG, "mDividerHeight:" + mDividerHeight); 316 // Initializes the trapezoid paint. 317 mTrapezoidHOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_start); 318 mTrapezoidVOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_bottom); 319 mTrapezoidPaint = new Paint(); 320 mTrapezoidPaint.setAntiAlias(true); 321 mTrapezoidPaint.setColor(mTrapezoidSolidColor); 322 mTrapezoidPaint.setStyle(Paint.Style.FILL); 323 mTrapezoidPaint.setPathEffect( 324 new CornerPathEffect( 325 resources.getDimensionPixelSize(R.dimen.chartview_trapezoid_radius))); 326 // Initializes for drawing text information. 327 mTextPadding = resources.getDimensionPixelSize(R.dimen.chartview_text_padding); 328 } 329 drawHorizontalDividers(Canvas canvas)330 private void drawHorizontalDividers(Canvas canvas) { 331 final int width = getWidth() - mIndent.right; 332 final int height = getHeight() - mIndent.top - mIndent.bottom; 333 // Draws the top divider line for 100% curve. 334 float offsetY = mIndent.top + mDividerWidth * .5f; 335 canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); 336 drawPercentage(canvas, /*index=*/ 0, offsetY); 337 338 // Draws the center divider line for 50% curve. 339 final float availableSpace = 340 height - mDividerWidth * 2 - mTrapezoidVOffset - mDividerHeight; 341 offsetY = mIndent.top + mDividerWidth + availableSpace * .5f; 342 canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); 343 drawPercentage(canvas, /*index=*/ 1, offsetY); 344 345 // Draws the bottom divider line for 0% curve. 346 offsetY = mIndent.top + (height - mDividerHeight - mDividerWidth * .5f); 347 canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); 348 drawPercentage(canvas, /*index=*/ 2, offsetY); 349 } 350 drawPercentage(Canvas canvas, int index, float offsetY)351 private void drawPercentage(Canvas canvas, int index, float offsetY) { 352 if (mTextPaint != null) { 353 mTextPaint.setTextAlign(Paint.Align.RIGHT); 354 canvas.drawText( 355 mPercentages[index], 356 getWidth(), 357 offsetY + mPercentageBounds[index].height() * .5f, 358 mTextPaint); 359 } 360 } 361 drawVerticalDividers(Canvas canvas)362 private void drawVerticalDividers(Canvas canvas) { 363 final int width = getWidth() - mIndent.right; 364 final int dividerCount = mTrapezoidSlots.length + 1; 365 final float dividerSpace = dividerCount * mDividerWidth; 366 final float unitWidth = (width - dividerSpace) / (float) mTrapezoidSlots.length; 367 final float bottomY = getHeight() - mIndent.bottom; 368 final float startY = bottomY - mDividerHeight; 369 final float trapezoidSlotOffset = mTrapezoidHOffset + mDividerWidth * .5f; 370 // Draws each vertical dividers. 371 float startX = mDividerWidth * .5f; 372 for (int index = 0; index < dividerCount; index++) { 373 canvas.drawLine(startX, startY, startX, bottomY, mDividerPaint); 374 final float nextX = startX + mDividerWidth + unitWidth; 375 // Updates the trapezoid slots for drawing. 376 if (index < mTrapezoidSlots.length) { 377 mTrapezoidSlots[index].mLeft = round(startX + trapezoidSlotOffset); 378 mTrapezoidSlots[index].mRight = round(nextX - trapezoidSlotOffset); 379 } 380 startX = nextX; 381 } 382 // Draws the axis label slot information. 383 if (mViewModel != null) { 384 final float baselineY = getHeight() - mTextPadding; 385 Rect[] axisLabelDisplayAreas; 386 switch (mViewModel.axisLabelPosition()) { 387 case CENTER_OF_TRAPEZOIDS: 388 axisLabelDisplayAreas = getAxisLabelDisplayAreas( 389 /* size= */ mViewModel.size() - 1, 390 /* baselineX= */ mDividerWidth + unitWidth * .5f, 391 /* offsetX= */ mDividerWidth + unitWidth, 392 baselineY, 393 /* shiftFirstAndLast= */ false); 394 break; 395 case BETWEEN_TRAPEZOIDS: 396 default: 397 axisLabelDisplayAreas = getAxisLabelDisplayAreas( 398 /* size= */ mViewModel.size(), 399 /* baselineX= */ mDividerWidth * .5f, 400 /* offsetX= */ mDividerWidth + unitWidth, 401 baselineY, 402 /* shiftFirstAndLast= */ true); 403 break; 404 } 405 drawAxisLabels(canvas, axisLabelDisplayAreas, baselineY); 406 } 407 } 408 409 /** 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)410 private Rect[] getAxisLabelDisplayAreas(final int size, final float baselineX, 411 final float offsetX, final float baselineY, final boolean shiftFirstAndLast) { 412 final Rect[] result = new Rect[size]; 413 for (int index = 0; index < result.length; index++) { 414 final float width = mAxisLabelsBounds.get(index).width(); 415 float middle = baselineX + index * offsetX; 416 if (shiftFirstAndLast) { 417 if (index == 0) { 418 middle += width * .5f; 419 } 420 if (index == size - 1) { 421 middle -= width * .5f; 422 } 423 } 424 final float left = middle - width * .5f; 425 final float right = left + width; 426 final float top = baselineY + mAxisLabelsBounds.get(index).top; 427 final float bottom = top + mAxisLabelsBounds.get(index).height(); 428 result[index] = new Rect(round(left), round(top), round(right), round(bottom)); 429 } 430 return result; 431 } 432 drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY)433 private void drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY) { 434 final int lastIndex = displayAreas.length - 1; 435 // Suppose first and last labels are always able to draw. 436 drawAxisLabelText(canvas, 0, displayAreas[0], baselineY); 437 drawAxisLabelText(canvas, lastIndex, displayAreas[lastIndex], baselineY); 438 drawAxisLabelsBetweenStartIndexAndEndIndex(canvas, displayAreas, 0, lastIndex, baselineY); 439 } 440 441 /** 442 * Recursively draws axis labels between the start index and the end index. If the inner number 443 * can be exactly divided into 2 parts, check and draw the middle index label and then 444 * recursively draw the 2 parts. Otherwise, divide into 3 parts. Check and draw the middle two 445 * labels and then recursively draw the 3 parts. If there are any overlaps, skip drawing and go 446 * back to the uplevel of the recursion. 447 */ drawAxisLabelsBetweenStartIndexAndEndIndex(Canvas canvas, final Rect[] displayAreas, final int startIndex, final int endIndex, final float baselineY)448 private void drawAxisLabelsBetweenStartIndexAndEndIndex(Canvas canvas, 449 final Rect[] displayAreas, final int startIndex, final int endIndex, 450 final float baselineY) { 451 if (endIndex - startIndex <= 1) { 452 return; 453 } 454 if ((endIndex - startIndex) % 2 == 0) { 455 int middleIndex = (startIndex + endIndex) / 2; 456 if (hasOverlap(displayAreas, startIndex, middleIndex) 457 || hasOverlap(displayAreas, middleIndex, endIndex)) { 458 return; 459 } 460 drawAxisLabelText(canvas, middleIndex, displayAreas[middleIndex], baselineY); 461 drawAxisLabelsBetweenStartIndexAndEndIndex( 462 canvas, displayAreas, startIndex, middleIndex, baselineY); 463 drawAxisLabelsBetweenStartIndexAndEndIndex( 464 canvas, displayAreas, middleIndex, endIndex, baselineY); 465 } else { 466 int middleIndex1 = startIndex + round((endIndex - startIndex) / 3f); 467 int middleIndex2 = startIndex + round((endIndex - startIndex) * 2 / 3f); 468 if (hasOverlap(displayAreas, startIndex, middleIndex1) 469 || hasOverlap(displayAreas, middleIndex1, middleIndex2) 470 || hasOverlap(displayAreas, middleIndex2, endIndex)) { 471 return; 472 } 473 drawAxisLabelText(canvas, middleIndex1, displayAreas[middleIndex1], baselineY); 474 drawAxisLabelText(canvas, middleIndex2, displayAreas[middleIndex2], baselineY); 475 drawAxisLabelsBetweenStartIndexAndEndIndex( 476 canvas, displayAreas, startIndex, middleIndex1, baselineY); 477 drawAxisLabelsBetweenStartIndexAndEndIndex( 478 canvas, displayAreas, middleIndex1, middleIndex2, baselineY); 479 drawAxisLabelsBetweenStartIndexAndEndIndex( 480 canvas, displayAreas, middleIndex2, endIndex, baselineY); 481 } 482 } 483 hasOverlap( final Rect[] displayAreas, final int leftIndex, final int rightIndex)484 private boolean hasOverlap( 485 final Rect[] displayAreas, final int leftIndex, final int rightIndex) { 486 return displayAreas[leftIndex].right + mTextPadding * 2.3f > displayAreas[rightIndex].left; 487 } 488 drawAxisLabelText( Canvas canvas, final int index, final Rect displayArea, final float baselineY)489 private void drawAxisLabelText( 490 Canvas canvas, final int index, final Rect displayArea, final float baselineY) { 491 mTextPaint.setTextAlign(Paint.Align.CENTER); 492 canvas.drawText( 493 mViewModel.getText(index), 494 displayArea.centerX(), 495 baselineY, 496 mTextPaint); 497 } 498 drawTrapezoids(Canvas canvas)499 private void drawTrapezoids(Canvas canvas) { 500 // Ignores invalid trapezoid data. 501 if (mViewModel == null) { 502 return; 503 } 504 final float trapezoidBottom = 505 getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth 506 - mTrapezoidVOffset; 507 final float availableSpace = 508 trapezoidBottom - mDividerWidth * .5f - mIndent.top - mTrapezoidVOffset; 509 final float unitHeight = availableSpace / 100f; 510 // Draws all trapezoid shapes into the canvas. 511 final Path trapezoidPath = new Path(); 512 Path trapezoidCurvePath = null; 513 for (int index = 0; index < mTrapezoidSlots.length; index++) { 514 // Not draws the trapezoid for corner or not initialization cases. 515 if (!isValidToDraw(mViewModel, index)) { 516 continue; 517 } 518 // Configures the trapezoid paint color. 519 final int trapezoidColor = (mViewModel.selectedIndex() == index 520 || mViewModel.selectedIndex() == BatteryChartViewModel.SELECTED_INDEX_ALL) 521 ? mTrapezoidSolidColor : mTrapezoidColor; 522 final boolean isHoverState = mHoveredIndex == index && isValidToDraw(mViewModel, 523 mHoveredIndex); 524 mTrapezoidPaint.setColor(isHoverState ? mTrapezoidHoverColor : trapezoidColor); 525 526 final float leftTop = round( 527 trapezoidBottom - requireNonNull(mViewModel.getLevel(index)) * unitHeight); 528 final float rightTop = round(trapezoidBottom 529 - requireNonNull(mViewModel.getLevel(index + 1)) * unitHeight); 530 trapezoidPath.reset(); 531 trapezoidPath.moveTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); 532 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); 533 trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, rightTop); 534 trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, trapezoidBottom); 535 // A tricky way to make the trapezoid shape drawing the rounded corner. 536 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); 537 trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); 538 // Draws the trapezoid shape into canvas. 539 canvas.drawPath(trapezoidPath, mTrapezoidPaint); 540 } 541 } 542 543 // Searches the corresponding trapezoid index from x location. getTrapezoidIndex(float x)544 private int getTrapezoidIndex(float x) { 545 if (mTrapezoidSlots == null) { 546 return BatteryChartViewModel.SELECTED_INDEX_INVALID; 547 } 548 for (int index = 0; index < mTrapezoidSlots.length; index++) { 549 final TrapezoidSlot slot = mTrapezoidSlots[index]; 550 if (x >= slot.mLeft - mTrapezoidHOffset 551 && x <= slot.mRight + mTrapezoidHOffset) { 552 return index; 553 } 554 } 555 return BatteryChartViewModel.SELECTED_INDEX_INVALID; 556 } 557 initializeAxisLabelsBounds()558 private void initializeAxisLabelsBounds() { 559 mAxisLabelsBounds.clear(); 560 for (int i = 0; i < mViewModel.size(); i++) { 561 mAxisLabelsBounds.add(new Rect()); 562 } 563 } 564 isTrapezoidValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)565 private static boolean isTrapezoidValid( 566 @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) { 567 return viewModel.getLevel(trapezoidIndex) != null 568 && viewModel.getLevel(trapezoidIndex + 1) != null; 569 } 570 isTrapezoidIndexValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)571 private static boolean isTrapezoidIndexValid( 572 @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) { 573 return viewModel != null 574 && trapezoidIndex >= 0 575 && trapezoidIndex < viewModel.size() - 1; 576 } 577 isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex)578 private static boolean isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex) { 579 return isTrapezoidIndexValid(viewModel, trapezoidIndex) 580 && isTrapezoidValid(viewModel, trapezoidIndex); 581 } 582 hasAnyValidTrapezoid(@onNull BatteryChartViewModel viewModel)583 private static boolean hasAnyValidTrapezoid(@NonNull BatteryChartViewModel viewModel) { 584 // Sets the chart is clickable if there is at least one valid item in it. 585 for (int trapezoidIndex = 0; trapezoidIndex < viewModel.size() - 1; trapezoidIndex++) { 586 if (isTrapezoidValid(viewModel, trapezoidIndex)) { 587 return true; 588 } 589 } 590 return false; 591 } 592 getPercentages()593 private static String[] getPercentages() { 594 return new String[]{ 595 formatPercentage(/*percentage=*/ 100, /*round=*/ true), 596 formatPercentage(/*percentage=*/ 50, /*round=*/ true), 597 formatPercentage(/*percentage=*/ 0, /*round=*/ true)}; 598 } 599 600 private class BatteryChartAccessibilityNodeProvider extends AccessibilityNodeProvider { 601 @Override createAccessibilityNodeInfo(int virtualViewId)602 public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { 603 if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) { 604 final AccessibilityNodeInfo hostInfo = 605 new AccessibilityNodeInfo(BatteryChartView.this); 606 for (int index = 0; index < mViewModel.size() - 1; index++) { 607 hostInfo.addChild(BatteryChartView.this, index); 608 } 609 return hostInfo; 610 } 611 final int index = virtualViewId; 612 if (!isTrapezoidIndexValid(mViewModel, index)) { 613 Log.w(TAG, "Invalid virtual view id:" + index); 614 return null; 615 } 616 final AccessibilityNodeInfo childInfo = 617 new AccessibilityNodeInfo(BatteryChartView.this, index); 618 onInitializeAccessibilityNodeInfo(childInfo); 619 childInfo.setClickable(isValidToDraw(mViewModel, index)); 620 childInfo.setText(mViewModel.getFullText(index)); 621 childInfo.setContentDescription(mViewModel.getFullText(index)); 622 623 final Rect bounds = new Rect(); 624 getBoundsOnScreen(bounds, true); 625 final int hostLeft = bounds.left; 626 bounds.left = round(hostLeft + mTrapezoidSlots[index].mLeft); 627 bounds.right = round(hostLeft + mTrapezoidSlots[index].mRight); 628 childInfo.setBoundsInScreen(bounds); 629 return childInfo; 630 } 631 632 @Override performAction(int virtualViewId, int action, @Nullable Bundle arguments)633 public boolean performAction(int virtualViewId, int action, 634 @Nullable Bundle arguments) { 635 if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) { 636 return performAccessibilityAction(action, arguments); 637 } 638 switch (action) { 639 case AccessibilityNodeInfo.ACTION_CLICK: 640 onTrapezoidClicked(BatteryChartView.this, virtualViewId); 641 return true; 642 643 case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: 644 return sendAccessibilityEvent(virtualViewId, 645 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); 646 647 case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: 648 return sendAccessibilityEvent(virtualViewId, 649 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); 650 651 default: 652 return performAccessibilityAction(action, arguments); 653 } 654 } 655 } 656 657 // A container class for each trapezoid left and right location. 658 @VisibleForTesting 659 static final class TrapezoidSlot { 660 public float mLeft; 661 public float mRight; 662 663 @Override toString()664 public String toString() { 665 return String.format(Locale.US, "TrapezoidSlot[%f,%f]", mLeft, mRight); 666 } 667 } 668 } 669