1 /*
2  * Copyright (C) 2014 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.systemui.statusbar.notification.row;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.graphics.Canvas;
22 import android.graphics.Outline;
23 import android.graphics.Path;
24 import android.graphics.Rect;
25 import android.graphics.RectF;
26 import android.util.AttributeSet;
27 import android.util.IndentingPrintWriter;
28 import android.view.View;
29 import android.view.ViewOutlineProvider;
30 
31 import com.android.systemui.res.R;
32 import com.android.systemui.statusbar.notification.RoundableState;
33 import com.android.systemui.statusbar.notification.shared.NotificationsImprovedHunAnimation;
34 import com.android.systemui.statusbar.notification.stack.NotificationChildrenContainer;
35 import com.android.systemui.util.DumpUtilsKt;
36 
37 import java.io.PrintWriter;
38 
39 /**
40  * Like {@link ExpandableView}, but setting an outline for the height and clipping.
41  */
42 public abstract class ExpandableOutlineView extends ExpandableView {
43 
44     private RoundableState mRoundableState;
45     private static final Path EMPTY_PATH = new Path();
46     private final Rect mOutlineRect = new Rect();
47     private boolean mCustomOutline;
48     private float mOutlineAlpha = -1f;
49     private boolean mAlwaysRoundBothCorners;
50     private Path mTmpPath = new Path();
51 
52     /**
53      * {@code false} if the children views of the {@link ExpandableOutlineView} are translated when
54      * it is moved. Otherwise, the translation is set on the {@code ExpandableOutlineView} itself.
55      */
56     protected boolean mDismissUsingRowTranslationX = true;
57 
58     private float[] mTmpCornerRadii = new float[8];
59 
60     private final ViewOutlineProvider mProvider = new ViewOutlineProvider() {
61         @Override
62         public void getOutline(View view, Outline outline) {
63             if (!mCustomOutline && !hasRoundedCorner() && !mAlwaysRoundBothCorners) {
64                 // Only when translating just the contents, does the outline need to be shifted.
65                 int translation = !mDismissUsingRowTranslationX ? (int) getTranslation() : 0;
66                 int left = Math.max(translation, 0);
67                 int top = mClipTopAmount;
68                 int right = getWidth() + Math.min(translation, 0);
69                 int bottom = Math.max(getActualHeight() - mClipBottomAmount, top);
70                 outline.setRect(left, top, right, bottom);
71             } else {
72                 Path clipPath = getClipPath(false /* ignoreTranslation */);
73                 if (clipPath != null) {
74                     outline.setPath(clipPath);
75                 }
76             }
77             outline.setAlpha(mOutlineAlpha);
78         }
79     };
80 
81     @Override
getRoundableState()82     public RoundableState getRoundableState() {
83         return mRoundableState;
84     }
85 
86     @Override
getClipHeight()87     public int getClipHeight() {
88         if (mCustomOutline) {
89             return mOutlineRect.height();
90         }
91 
92         return super.getClipHeight();
93     }
94 
getClipPath(boolean ignoreTranslation)95     protected Path getClipPath(boolean ignoreTranslation) {
96         int left;
97         int top;
98         int right;
99         int bottom;
100         int height;
101         float topRadius = mAlwaysRoundBothCorners ? getMaxRadius() : getTopCornerRadius();
102         if (!mCustomOutline) {
103             // The outline just needs to be shifted if we're translating the contents. Otherwise
104             // it's already in the right place.
105             int translation = !mDismissUsingRowTranslationX && !ignoreTranslation
106                     ? (int) getTranslation() : 0;
107             int halfExtraWidth = (int) (mExtraWidthForClipping / 2.0f);
108             left = Math.max(translation, 0) - halfExtraWidth;
109             top = mClipTopAmount;
110             right = getWidth() + halfExtraWidth + Math.min(translation, 0);
111             // If the top is rounded we want the bottom to be at most at the top roundness, in order
112             // to avoid the shadow changing when scrolling up.
113             bottom = Math.max(mMinimumHeightForClipping,
114                     Math.max(getActualHeight() - mClipBottomAmount, (int) (top + topRadius)));
115         } else {
116             left = mOutlineRect.left;
117             top = mOutlineRect.top;
118             right = mOutlineRect.right;
119             bottom = mOutlineRect.bottom;
120         }
121         height = bottom - top;
122         if (height == 0) {
123             return EMPTY_PATH;
124         }
125         float bottomRadius = mAlwaysRoundBothCorners ? getMaxRadius() : getBottomCornerRadius();
126         if (!NotificationsImprovedHunAnimation.isEnabled() && (topRadius + bottomRadius > height)) {
127             float overShoot = topRadius + bottomRadius - height;
128             float currentTopRoundness = getTopRoundness();
129             float currentBottomRoundness = getBottomRoundness();
130             topRadius -= overShoot * currentTopRoundness
131                     / (currentTopRoundness + currentBottomRoundness);
132             bottomRadius -= overShoot * currentBottomRoundness
133                     / (currentTopRoundness + currentBottomRoundness);
134         }
135         getRoundedRectPath(left, top, right, bottom, topRadius, bottomRadius, mTmpPath);
136         return mTmpPath;
137     }
138 
139     /**
140      * Add a round rect in {@code outPath}
141      * @param outPath destination path
142      */
getRoundedRectPath( int left, int top, int right, int bottom, float topRoundness, float bottomRoundness, Path outPath)143     public void getRoundedRectPath(
144             int left,
145             int top,
146             int right,
147             int bottom,
148             float topRoundness,
149             float bottomRoundness,
150             Path outPath) {
151         outPath.reset();
152         mTmpCornerRadii[0] = topRoundness;
153         mTmpCornerRadii[1] = topRoundness;
154         mTmpCornerRadii[2] = topRoundness;
155         mTmpCornerRadii[3] = topRoundness;
156         mTmpCornerRadii[4] = bottomRoundness;
157         mTmpCornerRadii[5] = bottomRoundness;
158         mTmpCornerRadii[6] = bottomRoundness;
159         mTmpCornerRadii[7] = bottomRoundness;
160         outPath.addRoundRect(left, top, right, bottom, mTmpCornerRadii, Path.Direction.CW);
161     }
162 
ExpandableOutlineView(Context context, AttributeSet attrs)163     public ExpandableOutlineView(Context context, AttributeSet attrs) {
164         super(context, attrs);
165         setOutlineProvider(mProvider);
166         initDimens();
167     }
168 
169     @Override
drawChild(Canvas canvas, View child, long drawingTime)170     protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
171         canvas.save();
172         Path clipPath = null;
173         Path childClipPath = null;
174         if (childNeedsClipping(child)) {
175             clipPath = getCustomClipPath(child);
176             if (clipPath == null) {
177                 clipPath = getClipPath(false /* ignoreTranslation */);
178             }
179             // If the notification uses "RowTranslationX" as dismiss behavior, we should clip the
180             // children instead.
181             if (mDismissUsingRowTranslationX && child instanceof NotificationChildrenContainer) {
182                 childClipPath = clipPath;
183                 clipPath = null;
184             }
185         }
186 
187         if (child instanceof NotificationChildrenContainer) {
188             ((NotificationChildrenContainer) child).setChildClipPath(childClipPath);
189         }
190         if (clipPath != null) {
191             canvas.clipPath(clipPath);
192         }
193 
194         boolean result = super.drawChild(canvas, child, drawingTime);
195         canvas.restore();
196         return result;
197     }
198 
199     @Override
setExtraWidthForClipping(float extraWidthForClipping)200     public void setExtraWidthForClipping(float extraWidthForClipping) {
201         super.setExtraWidthForClipping(extraWidthForClipping);
202         invalidate();
203     }
204 
205     @Override
setMinimumHeightForClipping(int minimumHeightForClipping)206     public void setMinimumHeightForClipping(int minimumHeightForClipping) {
207         super.setMinimumHeightForClipping(minimumHeightForClipping);
208         invalidate();
209     }
210 
childNeedsClipping(View child)211     protected boolean childNeedsClipping(View child) {
212         return false;
213     }
214 
isClippingNeeded()215     protected boolean isClippingNeeded() {
216         // When translating the contents instead of the overall view, we need to make sure we clip
217         // rounded to the contents.
218         boolean forTranslation = getTranslation() != 0 && !mDismissUsingRowTranslationX;
219         return mAlwaysRoundBothCorners || mCustomOutline || forTranslation;
220     }
221 
initDimens()222     private void initDimens() {
223         Resources res = getResources();
224         mAlwaysRoundBothCorners = res.getBoolean(R.bool.config_clipNotificationsToOutline);
225         float maxRadius;
226         if (mAlwaysRoundBothCorners) {
227             maxRadius = res.getDimension(R.dimen.notification_shadow_radius);
228         } else {
229             maxRadius = res.getDimensionPixelSize(R.dimen.notification_corner_radius);
230         }
231         if (mRoundableState == null) {
232             mRoundableState = new RoundableState(this, this, maxRadius);
233         } else {
234             mRoundableState.setMaxRadius(maxRadius);
235         }
236         setClipToOutline(mAlwaysRoundBothCorners);
237     }
238 
239     @Override
applyRoundnessAndInvalidate()240     public void applyRoundnessAndInvalidate() {
241         invalidateOutline();
242         super.applyRoundnessAndInvalidate();
243     }
244 
onDensityOrFontScaleChanged()245     public void onDensityOrFontScaleChanged() {
246         initDimens();
247         applyRoundnessAndInvalidate();
248     }
249 
250     @Override
setActualHeight(int actualHeight, boolean notifyListeners)251     public void setActualHeight(int actualHeight, boolean notifyListeners) {
252         int previousHeight = getActualHeight();
253         super.setActualHeight(actualHeight, notifyListeners);
254         if (previousHeight != actualHeight) {
255             applyRoundnessAndInvalidate();
256         }
257     }
258 
259     @Override
setClipTopAmount(int clipTopAmount)260     public void setClipTopAmount(int clipTopAmount) {
261         int previousAmount = getClipTopAmount();
262         super.setClipTopAmount(clipTopAmount);
263         if (previousAmount != clipTopAmount) {
264             applyRoundnessAndInvalidate();
265         }
266     }
267 
268     @Override
setClipBottomAmount(int clipBottomAmount)269     public void setClipBottomAmount(int clipBottomAmount) {
270         int previousAmount = getClipBottomAmount();
271         super.setClipBottomAmount(clipBottomAmount);
272         if (previousAmount != clipBottomAmount) {
273             applyRoundnessAndInvalidate();
274         }
275     }
276 
setOutlineAlpha(float alpha)277     protected void setOutlineAlpha(float alpha) {
278         if (alpha != mOutlineAlpha) {
279             mOutlineAlpha = alpha;
280             applyRoundnessAndInvalidate();
281         }
282     }
283 
284     @Override
getOutlineAlpha()285     public float getOutlineAlpha() {
286         return mOutlineAlpha;
287     }
288 
setOutlineRect(RectF rect)289     protected void setOutlineRect(RectF rect) {
290         if (rect != null) {
291             setOutlineRect(rect.left, rect.top, rect.right, rect.bottom);
292         } else {
293             mCustomOutline = false;
294             applyRoundnessAndInvalidate();
295         }
296     }
297 
298     /**
299      * Set the dismiss behavior of the view.
300      *
301      * @param usingRowTranslationX {@code true} if the view should translate using regular
302      *                             translationX, otherwise the contents will be
303      *                             translated.
304      */
setDismissUsingRowTranslationX(boolean usingRowTranslationX)305     public void setDismissUsingRowTranslationX(boolean usingRowTranslationX) {
306         mDismissUsingRowTranslationX = usingRowTranslationX;
307     }
308 
309     @Override
getOutlineTranslation()310     public int getOutlineTranslation() {
311         if (mCustomOutline) {
312             return mOutlineRect.left;
313         }
314         if (mDismissUsingRowTranslationX) {
315             return 0;
316         }
317         return (int) getTranslation();
318     }
319 
updateOutline()320     public void updateOutline() {
321         if (mCustomOutline) {
322             return;
323         }
324         boolean hasOutline = needsOutline();
325         setOutlineProvider(hasOutline ? mProvider : null);
326     }
327 
328     /**
329      * @return Whether the view currently needs an outline. This is usually {@code false} in case
330      * it doesn't have a background.
331      */
needsOutline()332     protected boolean needsOutline() {
333         if (isChildInGroup()) {
334             return isGroupExpanded() && !isGroupExpansionChanging();
335         } else if (isSummaryWithChildren()) {
336             return !isGroupExpanded() || isGroupExpansionChanging();
337         }
338         return true;
339     }
340 
isOutlineShowing()341     public boolean isOutlineShowing() {
342         ViewOutlineProvider op = getOutlineProvider();
343         return op != null;
344     }
345 
setOutlineRect(float left, float top, float right, float bottom)346     protected void setOutlineRect(float left, float top, float right, float bottom) {
347         mCustomOutline = true;
348 
349         mOutlineRect.set((int) left, (int) top, (int) right, (int) bottom);
350 
351         // Outlines need to be at least 1 dp
352         mOutlineRect.bottom = (int) Math.max(top, mOutlineRect.bottom);
353         mOutlineRect.right = (int) Math.max(left, mOutlineRect.right);
354         applyRoundnessAndInvalidate();
355     }
356 
getCustomClipPath(View child)357     public Path getCustomClipPath(View child) {
358         return null;
359     }
360 
361     @Override
dump(PrintWriter pwOriginal, String[] args)362     public void dump(PrintWriter pwOriginal, String[] args) {
363         IndentingPrintWriter pw = DumpUtilsKt.asIndenting(pwOriginal);
364         super.dump(pw, args);
365         DumpUtilsKt.withIncreasedIndent(pw, () -> {
366             pw.println(getRoundableState().debugString());
367             if (DUMP_VERBOSE) {
368                 pw.println("mCustomOutline: " + mCustomOutline + " mOutlineRect: " + mOutlineRect);
369                 pw.println("mOutlineAlpha: " + mOutlineAlpha);
370                 pw.println("mAlwaysRoundBothCorners: " + mAlwaysRoundBothCorners);
371             }
372         });
373     }
374 
375 }
376