1 /* 2 * Copyright (C) 2016 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 17 package com.android.internal.widget; 18 19 import static android.app.Flags.notificationsRedesignTemplates; 20 import static android.app.Notification.CallStyle.DEBUG_NEW_ACTION_LAYOUT; 21 import static android.app.Flags.evenlyDividedCallStyleActionLayout; 22 23 import android.annotation.DimenRes; 24 import android.app.Flags; 25 import android.app.Notification; 26 import android.content.Context; 27 import android.content.res.TypedArray; 28 import android.graphics.drawable.RippleDrawable; 29 import android.util.AttributeSet; 30 import android.util.Log; 31 import android.view.Gravity; 32 import android.view.RemotableViewMethod; 33 import android.view.View; 34 import android.view.ViewGroup; 35 import android.widget.LinearLayout; 36 import android.widget.RemoteViews; 37 import android.widget.TextView; 38 39 import com.android.internal.R; 40 41 import java.util.ArrayList; 42 import java.util.Comparator; 43 44 /** 45 * Layout for notification actions that ensures that no action consumes more than their share of 46 * the remaining available width, and the last action consumes the remaining space. 47 */ 48 @RemoteViews.RemoteView 49 public class NotificationActionListLayout extends LinearLayout { 50 private final int mGravity; 51 private int mTotalWidth = 0; 52 private int mExtraStartPadding = 0; 53 private ArrayList<TextViewInfo> mMeasureOrderTextViews = new ArrayList<>(); 54 private ArrayList<View> mMeasureOrderOther = new ArrayList<>(); 55 private boolean mEmphasizedMode; 56 private boolean mEvenlyDividedMode; 57 private int mDefaultPaddingBottom; 58 private int mDefaultPaddingTop; 59 private int mEmphasizedPaddingTop; 60 private int mEmphasizedPaddingBottom; 61 private int mEmphasizedHeight; 62 private int mRegularHeight; 63 @DimenRes private int mCollapsibleIndentDimen; 64 int mNumNotGoneChildren; 65 int mNumPriorityChildren; 66 NotificationActionListLayout(Context context, AttributeSet attrs)67 public NotificationActionListLayout(Context context, AttributeSet attrs) { 68 this(context, attrs, 0); 69 } 70 NotificationActionListLayout(Context context, AttributeSet attrs, int defStyleAttr)71 public NotificationActionListLayout(Context context, AttributeSet attrs, int defStyleAttr) { 72 this(context, attrs, defStyleAttr, 0); 73 } 74 NotificationActionListLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)75 public NotificationActionListLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 76 super(context, attrs, defStyleAttr, defStyleRes); 77 78 mCollapsibleIndentDimen = Flags.notificationsRedesignTemplates() 79 ? R.dimen.notification_2025_actions_margin_start 80 : R.dimen.notification_actions_padding_start; 81 82 int[] attrIds = { android.R.attr.gravity }; 83 TypedArray ta = context.obtainStyledAttributes(attrs, attrIds, defStyleAttr, defStyleRes); 84 mGravity = ta.getInt(0, 0); 85 ta.recycle(); 86 } 87 isPriority(View actionView)88 private static boolean isPriority(View actionView) { 89 return actionView instanceof EmphasizedNotificationButton 90 && ((EmphasizedNotificationButton) actionView).isPriority(); 91 } 92 countAndRebuildMeasureOrder()93 private void countAndRebuildMeasureOrder() { 94 final int numChildren = getChildCount(); 95 int textViews = 0; 96 int otherViews = 0; 97 mNumNotGoneChildren = 0; 98 mNumPriorityChildren = 0; 99 100 for (int i = 0; i < numChildren; i++) { 101 View c = getChildAt(i); 102 if (c instanceof TextView) { 103 textViews++; 104 } else { 105 otherViews++; 106 } 107 if (c.getVisibility() != GONE) { 108 mNumNotGoneChildren++; 109 if (isPriority(c)) { 110 mNumPriorityChildren++; 111 } 112 } 113 } 114 115 // Rebuild the measure order if the number of children changed or the text length of 116 // any of the children changed. 117 boolean needRebuild = false; 118 if (textViews != mMeasureOrderTextViews.size() 119 || otherViews != mMeasureOrderOther.size()) { 120 needRebuild = true; 121 } 122 if (!needRebuild) { 123 final int size = mMeasureOrderTextViews.size(); 124 for (int i = 0; i < size; i++) { 125 if (mMeasureOrderTextViews.get(i).needsRebuild()) { 126 needRebuild = true; 127 break; 128 } 129 } 130 } 131 132 if (needRebuild) { 133 rebuildMeasureOrder(textViews, otherViews); 134 } 135 } 136 measureAndReturnEvenlyDividedWidth(int heightMeasureSpec, int innerWidth)137 private int measureAndReturnEvenlyDividedWidth(int heightMeasureSpec, int innerWidth) { 138 final int numChildren = getChildCount(); 139 int childMarginSum = 0; 140 for (int i = 0; i < numChildren; i++) { 141 final View child = getChildAt(i); 142 if (child.getVisibility() != GONE) { 143 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 144 childMarginSum += lp.leftMargin + lp.rightMargin; 145 } 146 } 147 148 final int innerWidthMinusChildMargins = innerWidth - childMarginSum; 149 final int childWidth = innerWidthMinusChildMargins / mNumNotGoneChildren; 150 final int childWidthMeasureSpec = 151 MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY); 152 153 if (DEBUG_NEW_ACTION_LAYOUT) { 154 Log.v(TAG, "measuring evenly divided width: " 155 + "numChildren = " + numChildren + ", " 156 + "innerWidth = " + innerWidth + "px, " 157 + "childMarginSum = " + childMarginSum + "px, " 158 + "innerWidthMinusChildMargins = " + innerWidthMinusChildMargins + "px, " 159 + "childWidth = " + childWidth + "px, " 160 + "childWidthMeasureSpec = " + MeasureSpec.toString(childWidthMeasureSpec)); 161 } 162 163 for (int i = 0; i < numChildren; i++) { 164 final View child = getChildAt(i); 165 if (child.getVisibility() != GONE) { 166 child.measure(childWidthMeasureSpec, heightMeasureSpec); 167 } 168 } 169 170 return innerWidth; 171 } 172 measureAndGetUsedWidth(int widthMeasureSpec, int heightMeasureSpec, int innerWidth, boolean collapsePriorityActions)173 private int measureAndGetUsedWidth(int widthMeasureSpec, int heightMeasureSpec, int innerWidth, 174 boolean collapsePriorityActions) { 175 final int numChildren = getChildCount(); 176 final boolean constrained = 177 MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED; 178 final int otherSize = mMeasureOrderOther.size(); 179 int usedWidth = 0; 180 181 int maxPriorityWidth = 0; 182 int measuredChildren = 0; 183 int measuredPriorityChildren = 0; 184 for (int i = 0; i < numChildren; i++) { 185 // Measure shortest children first. To avoid measuring twice, we approximate by looking 186 // at the text length. 187 final boolean isPriority; 188 final View c; 189 if (i < otherSize) { 190 c = mMeasureOrderOther.get(i); 191 isPriority = false; 192 } else { 193 TextViewInfo info = mMeasureOrderTextViews.get(i - otherSize); 194 c = info.mTextView; 195 isPriority = info.mIsPriority; 196 } 197 if (c.getVisibility() == GONE) { 198 continue; 199 } 200 MarginLayoutParams lp = (MarginLayoutParams) c.getLayoutParams(); 201 202 int usedWidthForChild = usedWidth; 203 if (constrained) { 204 // Make sure that this child doesn't consume more than its share of the remaining 205 // total available space. Not used space will benefit subsequent views. Since we 206 // measure in the order of (approx.) size, a large view can still take more than its 207 // share if the others are small. 208 int availableWidth = innerWidth - usedWidth; 209 int unmeasuredChildren = mNumNotGoneChildren - measuredChildren; 210 int maxWidthForChild = availableWidth / unmeasuredChildren; 211 if (isPriority && collapsePriorityActions) { 212 // Collapsing the actions to just the width required to show the icon. 213 if (maxPriorityWidth == 0) { 214 maxPriorityWidth = getResources().getDimensionPixelSize( 215 R.dimen.notification_actions_collapsed_priority_width); 216 } 217 maxWidthForChild = maxPriorityWidth + lp.leftMargin + lp.rightMargin; 218 } else if (isPriority) { 219 // Priority children get a larger maximum share of the total space: 220 // maximum priority share = (nPriority + 1) / (MAX + 1) 221 int unmeasuredPriorityChildren = mNumPriorityChildren 222 - measuredPriorityChildren; 223 int unmeasuredOtherChildren = unmeasuredChildren - unmeasuredPriorityChildren; 224 int widthReservedForOtherChildren = innerWidth * unmeasuredOtherChildren 225 / (Notification.MAX_ACTION_BUTTONS + 1); 226 int widthAvailableForPriority = availableWidth - widthReservedForOtherChildren; 227 maxWidthForChild = widthAvailableForPriority / unmeasuredPriorityChildren; 228 } 229 230 usedWidthForChild = innerWidth - maxWidthForChild; 231 } 232 233 measureChildWithMargins(c, widthMeasureSpec, usedWidthForChild, 234 heightMeasureSpec, 0 /* usedHeight */); 235 236 usedWidth += c.getMeasuredWidth() + lp.rightMargin + lp.leftMargin; 237 measuredChildren++; 238 if (isPriority) { 239 measuredPriorityChildren++; 240 } 241 } 242 243 int collapsibleIndent = mCollapsibleIndentDimen == 0 ? 0 244 : getResources().getDimensionPixelOffset(mCollapsibleIndentDimen); 245 if (innerWidth - usedWidth > collapsibleIndent) { 246 mExtraStartPadding = collapsibleIndent; 247 } else { 248 mExtraStartPadding = 0; 249 } 250 return usedWidth; 251 } 252 253 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)254 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 255 countAndRebuildMeasureOrder(); 256 final int innerWidth = MeasureSpec.getSize(widthMeasureSpec) - mPaddingLeft - mPaddingRight; 257 int usedWidth; 258 if (mEvenlyDividedMode) { 259 usedWidth = measureAndReturnEvenlyDividedWidth(heightMeasureSpec, innerWidth); 260 } else { 261 usedWidth = measureAndGetUsedWidth(widthMeasureSpec, heightMeasureSpec, innerWidth, 262 false /* collapsePriorityButtons */); 263 if (mNumPriorityChildren != 0 && usedWidth >= innerWidth) { 264 usedWidth = measureAndGetUsedWidth(widthMeasureSpec, heightMeasureSpec, innerWidth, 265 true /* collapsePriorityButtons */); 266 } 267 } 268 269 mTotalWidth = usedWidth + mPaddingRight + mPaddingLeft + mExtraStartPadding; 270 setMeasuredDimension(resolveSize(getSuggestedMinimumWidth(), widthMeasureSpec), 271 resolveSize(getSuggestedMinimumHeight(), heightMeasureSpec)); 272 } 273 rebuildMeasureOrder(int capacityText, int capacityOther)274 private void rebuildMeasureOrder(int capacityText, int capacityOther) { 275 clearMeasureOrder(); 276 mMeasureOrderTextViews.ensureCapacity(capacityText); 277 mMeasureOrderOther.ensureCapacity(capacityOther); 278 final int childCount = getChildCount(); 279 for (int i = 0; i < childCount; i++) { 280 View c = getChildAt(i); 281 if (c instanceof TextView && ((TextView) c).getText().length() > 0) { 282 mMeasureOrderTextViews.add(new TextViewInfo((TextView) c)); 283 } else { 284 mMeasureOrderOther.add(c); 285 } 286 } 287 mMeasureOrderTextViews.sort(MEASURE_ORDER_COMPARATOR); 288 } 289 clearMeasureOrder()290 private void clearMeasureOrder() { 291 mMeasureOrderOther.clear(); 292 mMeasureOrderTextViews.clear(); 293 } 294 295 @Override onViewAdded(View child)296 public void onViewAdded(View child) { 297 super.onViewAdded(child); 298 clearMeasureOrder(); 299 // For some reason ripples + notification actions seem to be an unhappy combination 300 // b/69474443 so just turn them off for now. 301 if (child.getBackground() instanceof RippleDrawable) { 302 ((RippleDrawable)child.getBackground()).setForceSoftware(true); 303 } 304 } 305 306 @Override onViewRemoved(View child)307 public void onViewRemoved(View child) { 308 super.onViewRemoved(child); 309 clearMeasureOrder(); 310 } 311 312 @Override onLayout(boolean changed, int left, int top, int right, int bottom)313 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 314 final boolean isLayoutRtl = isLayoutRtl(); 315 final int paddingTop = mPaddingTop; 316 final boolean centerAligned = (mGravity & Gravity.CENTER_HORIZONTAL) != 0; 317 318 int childTop; 319 int childLeft; 320 if (centerAligned) { 321 childLeft = mPaddingLeft + left + (right - left) / 2 - mTotalWidth / 2; 322 } else { 323 childLeft = mPaddingLeft; 324 int absoluteGravity = Gravity.getAbsoluteGravity(Gravity.START, getLayoutDirection()); 325 if (absoluteGravity == Gravity.RIGHT) { 326 childLeft += right - left - mTotalWidth; 327 } else { 328 // Put the extra start padding (if any) on the left when LTR 329 childLeft += mExtraStartPadding; 330 } 331 } 332 333 334 // Where bottom of child should go 335 final int height = bottom - top; 336 337 // Space available for child 338 int innerHeight = height - paddingTop - mPaddingBottom; 339 340 final int count = getChildCount(); 341 342 int start = 0; 343 int dir = 1; 344 //In case of RTL, start drawing from the last child. 345 if (isLayoutRtl) { 346 start = count - 1; 347 dir = -1; 348 } 349 350 for (int i = 0; i < count; i++) { 351 final int childIndex = start + dir * i; 352 final View child = getChildAt(childIndex); 353 if (child.getVisibility() != GONE) { 354 final int childWidth = child.getMeasuredWidth(); 355 final int childHeight = child.getMeasuredHeight(); 356 357 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 358 359 childTop = paddingTop + ((innerHeight - childHeight) / 2) 360 + lp.topMargin - lp.bottomMargin; 361 362 childLeft += lp.leftMargin; 363 child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 364 childLeft += childWidth + lp.rightMargin; 365 } 366 } 367 } 368 369 @Override onFinishInflate()370 protected void onFinishInflate() { 371 super.onFinishInflate(); 372 if (!notificationsRedesignTemplates()) { 373 mDefaultPaddingBottom = getPaddingBottom(); 374 mDefaultPaddingTop = getPaddingTop(); 375 updateHeights(); 376 } 377 } 378 updateHeights()379 private void updateHeights() { 380 if (notificationsRedesignTemplates()) { 381 return; 382 } 383 int inset = getResources().getDimensionPixelSize( 384 com.android.internal.R.dimen.button_inset_vertical_material); 385 mEmphasizedPaddingTop = getResources().getDimensionPixelSize( 386 com.android.internal.R.dimen.notification_content_margin) - inset; 387 // same padding on bottom and at end 388 mEmphasizedPaddingBottom = getResources().getDimensionPixelSize( 389 com.android.internal.R.dimen.notification_content_margin_end) - inset; 390 mEmphasizedHeight = mEmphasizedPaddingTop + mEmphasizedPaddingBottom 391 + getResources().getDimensionPixelSize( 392 com.android.internal.R.dimen.notification_action_emphasized_height); 393 mRegularHeight = getResources().getDimensionPixelSize( 394 com.android.internal.R.dimen.notification_action_list_height); 395 } 396 397 /** 398 * When buttons are in wrap mode, this is a padding that will be applied at the start of the 399 * layout of the actions, but only when those actions would fit with the entire padding 400 * visible. Otherwise, this padding will be omitted entirely. 401 */ 402 @RemotableViewMethod setCollapsibleIndentDimen(@imenRes int collapsibleIndentDimen)403 public void setCollapsibleIndentDimen(@DimenRes int collapsibleIndentDimen) { 404 if (mCollapsibleIndentDimen != collapsibleIndentDimen) { 405 mCollapsibleIndentDimen = collapsibleIndentDimen; 406 requestLayout(); 407 } 408 } 409 410 /** 411 * Sets whether the available width should be distributed evenly among the action buttons. 412 * 413 * When enabled, the available width (after subtracting this layout's padding and all of the 414 * buttons' margins) is divided by the number of (not-GONE) buttons, and each button is forced 415 * to that exact width, even if it is less <em>or more</em> width than they need. 416 * 417 * When disabled, the available width is allocated as buttons need; if that exceeds the 418 * available width, priority buttons are collapsed to just their icon to save space. 419 * 420 * @param evenlyDividedMode whether to enable evenly divided mode 421 */ 422 @RemotableViewMethod setEvenlyDividedMode(boolean evenlyDividedMode)423 public void setEvenlyDividedMode(boolean evenlyDividedMode) { 424 if (evenlyDividedMode && !evenlyDividedCallStyleActionLayout()) { 425 Log.e(TAG, "setEvenlyDividedMode(true) called with new action layout disabled; " 426 + "leaving evenly divided mode disabled"); 427 return; 428 } 429 430 if (evenlyDividedMode == mEvenlyDividedMode) { 431 return; 432 } 433 434 if (DEBUG_NEW_ACTION_LAYOUT) { 435 Log.v(TAG, "evenlyDividedMode changed to " + evenlyDividedMode + "; " 436 + "requesting layout"); 437 } 438 mEvenlyDividedMode = evenlyDividedMode; 439 requestLayout(); 440 } 441 442 /** 443 * Set whether the list is in a mode where some actions are emphasized. This will trigger an 444 * equal measuring where all actions are full height and change a few parameters like 445 * the padding. 446 */ 447 @RemotableViewMethod setEmphasizedMode(boolean emphasizedMode)448 public void setEmphasizedMode(boolean emphasizedMode) { 449 if (notificationsRedesignTemplates()) { 450 return; 451 } 452 mEmphasizedMode = emphasizedMode; 453 int height; 454 if (emphasizedMode) { 455 setPaddingRelative(getPaddingStart(), 456 mEmphasizedPaddingTop, 457 getPaddingEnd(), 458 mEmphasizedPaddingBottom); 459 setMinimumHeight(mEmphasizedHeight); 460 height = ViewGroup.LayoutParams.WRAP_CONTENT; 461 } else { 462 setPaddingRelative(getPaddingStart(), 463 mDefaultPaddingTop, 464 getPaddingEnd(), 465 mDefaultPaddingBottom); 466 height = mRegularHeight; 467 } 468 ViewGroup.LayoutParams layoutParams = getLayoutParams(); 469 layoutParams.height = height; 470 setLayoutParams(layoutParams); 471 } 472 getExtraMeasureHeight()473 public int getExtraMeasureHeight() { 474 // Note: the emphasized height is no longer different from the regular height when the 475 // notificationsRedesignTemplates flag is on. 476 if (!notificationsRedesignTemplates() && mEmphasizedMode) { 477 return mEmphasizedHeight - mRegularHeight; 478 } 479 return 0; 480 } 481 482 public static final Comparator<TextViewInfo> MEASURE_ORDER_COMPARATOR = (a, b) -> { 483 int priorityComparison = -Boolean.compare(a.mIsPriority, b.mIsPriority); 484 return priorityComparison != 0 485 ? priorityComparison 486 : Integer.compare(a.mTextLength, b.mTextLength); 487 }; 488 489 private static final class TextViewInfo { 490 final boolean mIsPriority; 491 final int mTextLength; 492 final TextView mTextView; 493 TextViewInfo(TextView textView)494 TextViewInfo(TextView textView) { 495 this.mIsPriority = isPriority(textView); 496 this.mTextLength = textView.getText().length(); 497 this.mTextView = textView; 498 } 499 needsRebuild()500 boolean needsRebuild() { 501 return mTextView.getText().length() != mTextLength 502 || isPriority(mTextView) != mIsPriority; 503 } 504 } 505 506 private static final String TAG = "NotificationActionListLayout"; 507 } 508