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