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