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