1 /*
2  * Copyright (C) 2015 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 android.view;
18 
19 import android.annotation.Nullable;
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.content.res.TypedArray;
23 import android.graphics.Rect;
24 import android.os.Trace;
25 import android.util.AttributeSet;
26 import android.widget.RemoteViews;
27 
28 import com.android.internal.R;
29 
30 import java.util.HashSet;
31 import java.util.Set;
32 
33 /**
34  * The top line of content in a notification view.
35  * This includes the text views and badges but excludes the icon and the expander.
36  *
37  * @hide
38  */
39 @RemoteViews.RemoteView
40 public class NotificationTopLineView extends ViewGroup {
41     private final OverflowAdjuster mOverflowAdjuster = new OverflowAdjuster();
42     private final int mGravityY;
43     private final int mChildMinWidth;
44     private final int mChildHideWidth;
45     @Nullable private View mAppName;
46     @Nullable private View mTitle;
47     private View mHeaderText;
48     private View mHeaderTextDivider;
49     private View mSecondaryHeaderText;
50     private View mSecondaryHeaderTextDivider;
51     private OnClickListener mFeedbackListener;
52     private HeaderTouchListener mTouchListener = new HeaderTouchListener();
53     private View mFeedbackIcon;
54     private int mHeaderTextMarginEnd;
55 
56     private Set<View> mViewsToDisappear = new HashSet<>();
57 
58     private int mMaxAscent;
59     private int mMaxDescent;
60 
NotificationTopLineView(Context context)61     public NotificationTopLineView(Context context) {
62         this(context, null);
63     }
64 
NotificationTopLineView(Context context, @Nullable AttributeSet attrs)65     public NotificationTopLineView(Context context, @Nullable AttributeSet attrs) {
66         this(context, attrs, 0);
67     }
68 
NotificationTopLineView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)69     public NotificationTopLineView(Context context, @Nullable AttributeSet attrs,
70             int defStyleAttr) {
71         this(context, attrs, defStyleAttr, 0);
72     }
73 
NotificationTopLineView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)74     public NotificationTopLineView(Context context, AttributeSet attrs, int defStyleAttr,
75             int defStyleRes) {
76         super(context, attrs, defStyleAttr, defStyleRes);
77         Resources res = getResources();
78         mChildMinWidth = res.getDimensionPixelSize(R.dimen.notification_header_shrink_min_width);
79         mChildHideWidth = res.getDimensionPixelSize(R.dimen.notification_header_shrink_hide_width);
80 
81         // NOTE: Implementation only supports TOP, BOTTOM, and CENTER_VERTICAL gravities,
82         // with CENTER_VERTICAL being the default.
83         int[] attrIds = {android.R.attr.gravity};
84         TypedArray ta = context.obtainStyledAttributes(attrs, attrIds, defStyleAttr, defStyleRes);
85         int gravity = ta.getInt(0, 0);
86         ta.recycle();
87         if ((gravity & Gravity.BOTTOM) == Gravity.BOTTOM) {
88             mGravityY = Gravity.BOTTOM;
89         } else if ((gravity & Gravity.TOP) == Gravity.TOP) {
90             mGravityY = Gravity.TOP;
91         } else {
92             mGravityY = Gravity.CENTER_VERTICAL;
93         }
94     }
95 
96     @Override
onFinishInflate()97     protected void onFinishInflate() {
98         super.onFinishInflate();
99         mAppName = findViewById(R.id.app_name_text);
100         mTitle = findViewById(R.id.title);
101         mHeaderText = findViewById(R.id.header_text);
102         mHeaderTextDivider = findViewById(R.id.header_text_divider);
103         mSecondaryHeaderText = findViewById(R.id.header_text_secondary);
104         mSecondaryHeaderTextDivider = findViewById(R.id.header_text_secondary_divider);
105         mFeedbackIcon = findViewById(R.id.feedback);
106     }
107 
108     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)109     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
110         Trace.beginSection("NotificationTopLineView#onMeasure");
111         final int givenWidth = MeasureSpec.getSize(widthMeasureSpec);
112         final int givenHeight = MeasureSpec.getSize(heightMeasureSpec);
113         final boolean wrapHeight = MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST;
114         int wrapContentWidthSpec = MeasureSpec.makeMeasureSpec(givenWidth, MeasureSpec.AT_MOST);
115         int heightSpec = MeasureSpec.makeMeasureSpec(givenHeight, MeasureSpec.AT_MOST);
116         int totalWidth = getPaddingStart();
117         int maxChildHeight = -1;
118         mMaxAscent = -1;
119         mMaxDescent = -1;
120         for (int i = 0; i < getChildCount(); i++) {
121             final View child = getChildAt(i);
122             if (child.getVisibility() == GONE) {
123                 // We'll give it the rest of the space in the end
124                 continue;
125             }
126             final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
127             int childWidthSpec = getChildMeasureSpec(wrapContentWidthSpec,
128                     lp.leftMargin + lp.rightMargin, lp.width);
129             int childHeightSpec = getChildMeasureSpec(heightSpec,
130                     lp.topMargin + lp.bottomMargin, lp.height);
131             child.measure(childWidthSpec, childHeightSpec);
132             totalWidth += lp.leftMargin + lp.rightMargin + child.getMeasuredWidth();
133             int childBaseline = child.getBaseline();
134             int childHeight = child.getMeasuredHeight();
135             if (childBaseline != -1) {
136                 mMaxAscent = Math.max(mMaxAscent, childBaseline);
137                 mMaxDescent = Math.max(mMaxDescent, childHeight - childBaseline);
138             }
139             maxChildHeight = Math.max(maxChildHeight, childHeight);
140         }
141 
142         mViewsToDisappear.clear();
143         // Ensure that there is at least enough space for the icons
144         int endMargin = Math.max(mHeaderTextMarginEnd, getPaddingEnd());
145         if (totalWidth > givenWidth - endMargin) {
146             int overFlow = totalWidth - givenWidth + endMargin;
147 
148             mOverflowAdjuster.resetForOverflow(overFlow, heightSpec)
149                     // First shrink the app name, down to a minimum size
150                     .adjust(mAppName, null, mChildMinWidth)
151                     // Next, shrink the header text (this usually has subText)
152                     //   This shrinks the subtext first, but not all the way (yet!)
153                     .adjust(mHeaderText, mHeaderTextDivider, mChildMinWidth)
154                     // Next, shrink the secondary header text  (this rarely has conversationTitle)
155                     .adjust(mSecondaryHeaderText, mSecondaryHeaderTextDivider, 0)
156                     // Next, shrink the title text (this has contentTitle; only in headerless views)
157                     .adjust(mTitle, null, mChildMinWidth)
158                     // Next, shrink the header down to 0 if still necessary.
159                     .adjust(mHeaderText, mHeaderTextDivider, 0)
160                     // Finally, shrink the title to 0 if necessary (media is super cramped)
161                     .adjust(mTitle, null, 0)
162                     // Clean up
163                     .finish();
164         }
165         setMeasuredDimension(givenWidth, wrapHeight ? maxChildHeight : givenHeight);
166         Trace.endSection();
167     }
168 
169     @Override
onLayout(boolean changed, int l, int t, int r, int b)170     protected void onLayout(boolean changed, int l, int t, int r, int b) {
171         final boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
172         final int width = getWidth();
173         int start = getPaddingStart();
174         int childCount = getChildCount();
175         int ownHeight = b - t;
176         int childSpace = ownHeight - mPaddingTop - mPaddingBottom;
177 
178         // Instead of centering the baseline, pick a baseline that centers views which align to it.
179         // Only used when mGravityY is CENTER_VERTICAL
180         int baselineY = mPaddingTop + ((childSpace - (mMaxAscent + mMaxDescent)) / 2) + mMaxAscent;
181 
182         for (int i = 0; i < childCount; i++) {
183             View child = getChildAt(i);
184             if (child.getVisibility() == GONE) {
185                 continue;
186             }
187             int childHeight = child.getMeasuredHeight();
188             MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
189 
190             // Calculate vertical alignment of the views, accounting for the view baselines
191             int childTop;
192             int childBaseline = child.getBaseline();
193             switch (mGravityY) {
194                 case Gravity.TOP:
195                     childTop = mPaddingTop + params.topMargin;
196                     if (childBaseline != -1) {
197                         childTop += mMaxAscent - childBaseline;
198                     }
199                     break;
200                 case Gravity.CENTER_VERTICAL:
201                     if (childBaseline != -1) {
202                         // Align baselines vertically only if the child is smaller than us
203                         if (childSpace - childHeight > 0) {
204                             childTop = baselineY - childBaseline;
205                         } else {
206                             childTop = mPaddingTop + (childSpace - childHeight) / 2;
207                         }
208                     } else {
209                         childTop = mPaddingTop + ((childSpace - childHeight) / 2)
210                                 + params.topMargin - params.bottomMargin;
211                     }
212                     break;
213                 case Gravity.BOTTOM:
214                     int childBottom = ownHeight - mPaddingBottom;
215                     childTop = childBottom - childHeight - params.bottomMargin;
216                     if (childBaseline != -1) {
217                         int descent = childHeight - childBaseline;
218                         childTop -= (mMaxDescent - descent);
219                     }
220                     break;
221                 default:
222                     childTop = mPaddingTop;
223             }
224             if (mViewsToDisappear.contains(child)) {
225                 child.layout(start, childTop, start, childTop + childHeight);
226             } else {
227                 start += params.getMarginStart();
228                 int end = start + child.getMeasuredWidth();
229                 int layoutLeft = isRtl ? width - end : start;
230                 int layoutRight = isRtl ? width - start : end;
231                 start = end + params.getMarginEnd();
232                 child.layout(layoutLeft, childTop, layoutRight, childTop + childHeight);
233             }
234         }
235         updateTouchListener();
236     }
237 
238     @Override
generateLayoutParams(AttributeSet attrs)239     public LayoutParams generateLayoutParams(AttributeSet attrs) {
240         return new MarginLayoutParams(getContext(), attrs);
241     }
242 
updateTouchListener()243     private void updateTouchListener() {
244         if (mFeedbackListener == null) {
245             setOnTouchListener(null);
246             return;
247         }
248         setOnTouchListener(mTouchListener);
249         mTouchListener.bindTouchRects();
250     }
251 
252     /**
253      * Sets onclick listener for feedback icon.
254      */
setFeedbackOnClickListener(OnClickListener l)255     public void setFeedbackOnClickListener(OnClickListener l) {
256         mFeedbackListener = l;
257         mFeedbackIcon.setOnClickListener(mFeedbackListener);
258         updateTouchListener();
259     }
260 
261     /**
262      * Sets the margin end for the text portion of the header, excluding right-aligned elements
263      *
264      * @param headerTextMarginEnd margin size
265      */
setHeaderTextMarginEnd(int headerTextMarginEnd)266     public void setHeaderTextMarginEnd(int headerTextMarginEnd) {
267         if (mHeaderTextMarginEnd != headerTextMarginEnd) {
268             mHeaderTextMarginEnd = headerTextMarginEnd;
269             requestLayout();
270         }
271     }
272 
273     /**
274      * Get the current margin end value for the header text
275      *
276      * @return margin size
277      */
getHeaderTextMarginEnd()278     public int getHeaderTextMarginEnd() {
279         return mHeaderTextMarginEnd;
280     }
281 
282     /**
283      * Set padding at the start of the view.
284      */
setPaddingStart(int paddingStart)285     public void setPaddingStart(int paddingStart) {
286         setPaddingRelative(paddingStart, getPaddingTop(), getPaddingEnd(), getPaddingBottom());
287     }
288 
289     private class HeaderTouchListener implements OnTouchListener {
290 
291         private Rect mFeedbackRect;
292         private int mTouchSlop;
293         private boolean mTrackGesture;
294         private float mDownX;
295         private float mDownY;
296 
HeaderTouchListener()297         HeaderTouchListener() {
298         }
299 
bindTouchRects()300         public void bindTouchRects() {
301             mFeedbackRect = getRectAroundView(mFeedbackIcon);
302             mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
303         }
304 
getRectAroundView(View view)305         private Rect getRectAroundView(View view) {
306             float size = 48 * getResources().getDisplayMetrics().density;
307             float width = Math.max(size, view.getWidth());
308             float height = Math.max(size, view.getHeight());
309             final Rect r = new Rect();
310             if (view.getVisibility() == GONE) {
311                 view = getFirstChildNotGone();
312                 r.left = (int) (view.getLeft() - width / 2.0f);
313             } else {
314                 r.left = (int) ((view.getLeft() + view.getRight()) / 2.0f - width / 2.0f);
315             }
316             r.top = (int) ((view.getTop() + view.getBottom()) / 2.0f - height / 2.0f);
317             r.bottom = (int) (r.top + height);
318             r.right = (int) (r.left + width);
319             return r;
320         }
321 
322         @Override
onTouch(View v, MotionEvent event)323         public boolean onTouch(View v, MotionEvent event) {
324             float x = event.getX();
325             float y = event.getY();
326             switch (event.getActionMasked() & MotionEvent.ACTION_MASK) {
327                 case MotionEvent.ACTION_DOWN:
328                     mTrackGesture = false;
329                     if (isInside(x, y)) {
330                         mDownX = x;
331                         mDownY = y;
332                         mTrackGesture = true;
333                         return true;
334                     }
335                     break;
336                 case MotionEvent.ACTION_MOVE:
337                     if (mTrackGesture) {
338                         if (Math.abs(mDownX - x) > mTouchSlop
339                                 || Math.abs(mDownY - y) > mTouchSlop) {
340                             mTrackGesture = false;
341                         }
342                     }
343                     break;
344                 case MotionEvent.ACTION_UP:
345                     if (mTrackGesture && onTouchUp(x, y, mDownX, mDownY)) {
346                         return true;
347                     }
348                     break;
349             }
350             return mTrackGesture;
351         }
352 
onTouchUp(float upX, float upY, float downX, float downY)353         private boolean onTouchUp(float upX, float upY, float downX, float downY) {
354             if (mFeedbackIcon.isVisibleToUser()
355                     && (mFeedbackRect.contains((int) upX, (int) upY)
356                     || mFeedbackRect.contains((int) downX, (int) downY))) {
357                 mFeedbackIcon.performClick();
358                 return true;
359             }
360             return false;
361         }
362 
isInside(float x, float y)363         private boolean isInside(float x, float y) {
364             return mFeedbackRect.contains((int) x, (int) y);
365         }
366     }
367 
getFirstChildNotGone()368     private View getFirstChildNotGone() {
369         for (int i = 0; i < getChildCount(); i++) {
370             final View child = getChildAt(i);
371             if (child.getVisibility() != GONE) {
372                 return child;
373             }
374         }
375         return this;
376     }
377 
378     @Override
hasOverlappingRendering()379     public boolean hasOverlappingRendering() {
380         return false;
381     }
382 
383     /**
384      * Determine if the given point is touching an active part of the top line.
385      */
isInTouchRect(float x, float y)386     public boolean isInTouchRect(float x, float y) {
387         if (mFeedbackListener == null) {
388             return false;
389         }
390         return mTouchListener.isInside(x, y);
391     }
392 
393     /**
394      * Perform a click on an active part of the top line, if touching.
395      */
onTouchUp(float upX, float upY, float downX, float downY)396     public boolean onTouchUp(float upX, float upY, float downX, float downY) {
397         if (mFeedbackListener == null) {
398             return false;
399         }
400         return mTouchListener.onTouchUp(upX, upY, downX, downY);
401     }
402 
403     private final class OverflowAdjuster {
404         private int mOverflow;
405         private int mHeightSpec;
406         private View mRegrowView;
407 
resetForOverflow(int overflow, int heightSpec)408         OverflowAdjuster resetForOverflow(int overflow, int heightSpec) {
409             mOverflow = overflow;
410             mHeightSpec = heightSpec;
411             mRegrowView = null;
412             return this;
413         }
414 
415         /**
416          * Shrink the targetView's width by up to overFlow, down to minimumWidth.
417          * @param targetView the view to shrink the width of
418          * @param targetDivider a divider view which should be set to 0 width if the targetView is
419          * @param minimumWidth the minimum width allowed for the targetView
420          * @return this object
421          */
adjust(View targetView, View targetDivider, int minimumWidth)422         OverflowAdjuster adjust(View targetView, View targetDivider, int minimumWidth) {
423             if (mOverflow <= 0 || targetView == null || targetView.getVisibility() == View.GONE) {
424                 return this;
425             }
426             final int oldWidth = targetView.getMeasuredWidth();
427             if (oldWidth <= minimumWidth) {
428                 return this;
429             }
430             // we're too big
431             int newSize = Math.max(minimumWidth, oldWidth - mOverflow);
432             if (minimumWidth == 0 && newSize < mChildHideWidth
433                     && mRegrowView != null && mRegrowView != targetView) {
434                 // View is so small it's better to hide it entirely (and its divider and margins)
435                 // so we can give that space back to another previously shrunken view.
436                 newSize = 0;
437             }
438 
439             int childWidthSpec = MeasureSpec.makeMeasureSpec(newSize, MeasureSpec.AT_MOST);
440             targetView.measure(childWidthSpec, mHeightSpec);
441             mOverflow -= oldWidth - newSize;
442 
443             if (newSize == 0) {
444                 mViewsToDisappear.add(targetView);
445                 mOverflow -= getHorizontalMargins(targetView);
446                 if (targetDivider != null && targetDivider.getVisibility() != GONE) {
447                     mViewsToDisappear.add(targetDivider);
448                     int oldDividerWidth = targetDivider.getMeasuredWidth();
449                     int dividerWidthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.AT_MOST);
450                     targetDivider.measure(dividerWidthSpec, mHeightSpec);
451                     mOverflow -= (oldDividerWidth + getHorizontalMargins(targetDivider));
452                 }
453             }
454             if (mOverflow < 0 && mRegrowView != null) {
455                 // We're now under-flowing, so regrow the last view.
456                 final int regrowCurrentSize = mRegrowView.getMeasuredWidth();
457                 final int maxSize = regrowCurrentSize - mOverflow;
458                 int regrowWidthSpec = MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.AT_MOST);
459                 mRegrowView.measure(regrowWidthSpec, mHeightSpec);
460                 finish();
461                 return this;
462             }
463 
464             if (newSize != 0) {
465                 // if we shrunk this view (but did not completely hide it) store it for potential
466                 // re-growth if we proactively shorten a future view.
467                 mRegrowView = targetView;
468             }
469             return this;
470         }
471 
finish()472         void finish() {
473             resetForOverflow(0, 0);
474         }
475 
getHorizontalMargins(View view)476         private int getHorizontalMargins(View view) {
477             MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
478             return params.getMarginStart() + params.getMarginEnd();
479         }
480     }
481 }
482