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