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