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