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.view.View;
28 import android.view.ViewOutlineProvider;
29 
30 import com.android.settingslib.Utils;
31 import com.android.systemui.R;
32 import com.android.systemui.statusbar.notification.AnimatableProperty;
33 import com.android.systemui.statusbar.notification.PropertyAnimator;
34 import com.android.systemui.statusbar.notification.stack.AnimationProperties;
35 import com.android.systemui.statusbar.notification.stack.StackStateAnimator;
36 
37 /**
38  * Like {@link ExpandableView}, but setting an outline for the height and clipping.
39  */
40 public abstract class ExpandableOutlineView extends ExpandableView {
41 
42     private static final AnimatableProperty TOP_ROUNDNESS = AnimatableProperty.from(
43             "topRoundness",
44             ExpandableOutlineView::setTopRoundnessInternal,
45             ExpandableOutlineView::getCurrentTopRoundness,
46             R.id.top_roundess_animator_tag,
47             R.id.top_roundess_animator_end_tag,
48             R.id.top_roundess_animator_start_tag);
49     private static final AnimatableProperty BOTTOM_ROUNDNESS = AnimatableProperty.from(
50             "bottomRoundness",
51             ExpandableOutlineView::setBottomRoundnessInternal,
52             ExpandableOutlineView::getCurrentBottomRoundness,
53             R.id.bottom_roundess_animator_tag,
54             R.id.bottom_roundess_animator_end_tag,
55             R.id.bottom_roundess_animator_start_tag);
56     private static final AnimationProperties ROUNDNESS_PROPERTIES =
57             new AnimationProperties().setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD);
58     private static final Path EMPTY_PATH = new Path();
59 
60     private final Rect mOutlineRect = new Rect();
61     private final Path mClipPath = new Path();
62     private boolean mCustomOutline;
63     private float mOutlineAlpha = -1f;
64     protected float mOutlineRadius;
65     private boolean mAlwaysRoundBothCorners;
66     private Path mTmpPath = new Path();
67     private float mCurrentBottomRoundness;
68     private float mCurrentTopRoundness;
69     private float mBottomRoundness;
70     private float mTopRoundness;
71     private int mBackgroundTop;
72 
73     /**
74      * {@code true} if the children views of the {@link ExpandableOutlineView} are translated when
75      * it is moved. Otherwise, the translation is set on the {@code ExpandableOutlineView} itself.
76      */
77     protected boolean mShouldTranslateContents;
78     private boolean mTopAmountRounded;
79     private float mDistanceToTopRoundness = -1;
80 
81     private final ViewOutlineProvider mProvider = new ViewOutlineProvider() {
82         @Override
83         public void getOutline(View view, Outline outline) {
84             if (!mCustomOutline && mCurrentTopRoundness == 0.0f
85                     && mCurrentBottomRoundness == 0.0f && !mAlwaysRoundBothCorners
86                     && !mTopAmountRounded) {
87                 int translation = mShouldTranslateContents ? (int) getTranslation() : 0;
88                 int left = Math.max(translation, 0);
89                 int top = mClipTopAmount + mBackgroundTop;
90                 int right = getWidth() + Math.min(translation, 0);
91                 int bottom = Math.max(getActualHeight() - mClipBottomAmount, top);
92                 outline.setRect(left, top, right, bottom);
93             } else {
94                 Path clipPath = getClipPath(false /* ignoreTranslation */);
95                 if (clipPath != null && clipPath.isConvex()) {
96                     // The path might not be convex in border cases where the view is small and
97                     // clipped
98                     outline.setConvexPath(clipPath);
99                 }
100             }
101             outline.setAlpha(mOutlineAlpha);
102         }
103     };
104 
getClipPath(boolean ignoreTranslation)105     protected Path getClipPath(boolean ignoreTranslation) {
106         int left;
107         int top;
108         int right;
109         int bottom;
110         int height;
111         float topRoundness = mAlwaysRoundBothCorners
112                 ? mOutlineRadius : getCurrentBackgroundRadiusTop();
113         if (!mCustomOutline) {
114             int translation = mShouldTranslateContents && !ignoreTranslation
115                     ? (int) getTranslation() : 0;
116             int halfExtraWidth = (int) (mExtraWidthForClipping / 2.0f);
117             left = Math.max(translation, 0) - halfExtraWidth;
118             top = mClipTopAmount + mBackgroundTop;
119             right = getWidth() + halfExtraWidth + Math.min(translation, 0);
120             // If the top is rounded we want the bottom to be at most at the top roundness, in order
121             // to avoid the shadow changing when scrolling up.
122             bottom = Math.max(mMinimumHeightForClipping,
123                     Math.max(getActualHeight() - mClipBottomAmount, (int) (top + topRoundness)));
124         } else {
125             left = mOutlineRect.left;
126             top = mOutlineRect.top;
127             right = mOutlineRect.right;
128             bottom = mOutlineRect.bottom;
129         }
130         height = bottom - top;
131         if (height == 0) {
132             return EMPTY_PATH;
133         }
134         float bottomRoundness = mAlwaysRoundBothCorners
135                 ? mOutlineRadius : getCurrentBackgroundRadiusBottom();
136         if (topRoundness + bottomRoundness > height) {
137             float overShoot = topRoundness + bottomRoundness - height;
138             topRoundness -= overShoot * mCurrentTopRoundness
139                     / (mCurrentTopRoundness + mCurrentBottomRoundness);
140             bottomRoundness -= overShoot * mCurrentBottomRoundness
141                     / (mCurrentTopRoundness + mCurrentBottomRoundness);
142         }
143         getRoundedRectPath(left, top, right, bottom, topRoundness,
144                 bottomRoundness, mTmpPath);
145         return mTmpPath;
146     }
147 
getRoundedRectPath(int left, int top, int right, int bottom, float topRoundness, float bottomRoundness, Path outPath)148     public static void getRoundedRectPath(int left, int top, int right, int bottom,
149             float topRoundness, float bottomRoundness, Path outPath) {
150         outPath.reset();
151         int width = right - left;
152         float topRoundnessX = topRoundness;
153         float bottomRoundnessX = bottomRoundness;
154         topRoundnessX = Math.min(width / 2, topRoundnessX);
155         bottomRoundnessX = Math.min(width / 2, bottomRoundnessX);
156         if (topRoundness > 0.0f) {
157             outPath.moveTo(left, top + topRoundness);
158             outPath.quadTo(left, top, left + topRoundnessX, top);
159             outPath.lineTo(right - topRoundnessX, top);
160             outPath.quadTo(right, top, right, top + topRoundness);
161         } else {
162             outPath.moveTo(left, top);
163             outPath.lineTo(right, top);
164         }
165         if (bottomRoundness > 0.0f) {
166             outPath.lineTo(right, bottom - bottomRoundness);
167             outPath.quadTo(right, bottom, right - bottomRoundnessX, bottom);
168             outPath.lineTo(left + bottomRoundnessX, bottom);
169             outPath.quadTo(left, bottom, left, bottom - bottomRoundness);
170         } else {
171             outPath.lineTo(right, bottom);
172             outPath.lineTo(left, bottom);
173         }
174         outPath.close();
175     }
176 
ExpandableOutlineView(Context context, AttributeSet attrs)177     public ExpandableOutlineView(Context context, AttributeSet attrs) {
178         super(context, attrs);
179         setOutlineProvider(mProvider);
180         initDimens();
181     }
182 
183     @Override
drawChild(Canvas canvas, View child, long drawingTime)184     protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
185         canvas.save();
186         Path intersectPath = null;
187         if (mTopAmountRounded && topAmountNeedsClipping()) {
188             int left = (int) (- mExtraWidthForClipping / 2.0f);
189             int top = (int) (mClipTopAmount - mDistanceToTopRoundness);
190             int right = getWidth() + (int) (mExtraWidthForClipping + left);
191             int bottom = (int) Math.max(mMinimumHeightForClipping,
192                     Math.max(getActualHeight() - mClipBottomAmount, top + mOutlineRadius));
193             ExpandableOutlineView.getRoundedRectPath(left, top, right, bottom, mOutlineRadius,
194                     0.0f,
195                     mClipPath);
196             intersectPath = mClipPath;
197         }
198         boolean clipped = false;
199         if (childNeedsClipping(child)) {
200             Path clipPath = getCustomClipPath(child);
201             if (clipPath == null) {
202                 clipPath = getClipPath(false /* ignoreTranslation */);
203             }
204             if (clipPath != null) {
205                 if (intersectPath != null) {
206                     clipPath.op(intersectPath, Path.Op.INTERSECT);
207                 }
208                 canvas.clipPath(clipPath);
209                 clipped = true;
210             }
211         }
212         if (!clipped && intersectPath != null) {
213             canvas.clipPath(intersectPath);
214         }
215         boolean result = super.drawChild(canvas, child, drawingTime);
216         canvas.restore();
217         return result;
218     }
219 
220     @Override
setExtraWidthForClipping(float extraWidthForClipping)221     public void setExtraWidthForClipping(float extraWidthForClipping) {
222         super.setExtraWidthForClipping(extraWidthForClipping);
223         invalidate();
224     }
225 
226     @Override
setMinimumHeightForClipping(int minimumHeightForClipping)227     public void setMinimumHeightForClipping(int minimumHeightForClipping) {
228         super.setMinimumHeightForClipping(minimumHeightForClipping);
229         invalidate();
230     }
231 
232     @Override
setDistanceToTopRoundness(float distanceToTopRoundness)233     public void setDistanceToTopRoundness(float distanceToTopRoundness) {
234         super.setDistanceToTopRoundness(distanceToTopRoundness);
235         if (distanceToTopRoundness != mDistanceToTopRoundness) {
236             mTopAmountRounded = distanceToTopRoundness >= 0;
237             mDistanceToTopRoundness = distanceToTopRoundness;
238             applyRoundness();
239         }
240     }
241 
childNeedsClipping(View child)242     protected boolean childNeedsClipping(View child) {
243         return false;
244     }
245 
topAmountNeedsClipping()246     public boolean topAmountNeedsClipping() {
247         return true;
248     }
249 
isClippingNeeded()250     protected boolean isClippingNeeded() {
251         return mAlwaysRoundBothCorners || mCustomOutline || getTranslation() != 0 ;
252     }
253 
initDimens()254     private void initDimens() {
255         Resources res = getResources();
256         mShouldTranslateContents =
257                 res.getBoolean(R.bool.config_translateNotificationContentsOnSwipe);
258         mOutlineRadius = res.getDimension(R.dimen.notification_shadow_radius);
259         mAlwaysRoundBothCorners = res.getBoolean(R.bool.config_clipNotificationsToOutline);
260         if (!mAlwaysRoundBothCorners) {
261             mOutlineRadius = res.getDimensionPixelSize(
262                     Utils.getThemeAttr(mContext, android.R.attr.dialogCornerRadius));
263         }
264         setClipToOutline(mAlwaysRoundBothCorners);
265     }
266 
267     /**
268      * Set the topRoundness of this view.
269      * @return Whether the roundness was changed.
270      */
setTopRoundness(float topRoundness, boolean animate)271     public boolean setTopRoundness(float topRoundness, boolean animate) {
272         if (mTopRoundness != topRoundness) {
273             mTopRoundness = topRoundness;
274             PropertyAnimator.setProperty(this, TOP_ROUNDNESS, topRoundness,
275                     ROUNDNESS_PROPERTIES, animate);
276             return true;
277         }
278         return false;
279     }
280 
applyRoundness()281     protected void applyRoundness() {
282         invalidateOutline();
283         invalidate();
284     }
285 
getCurrentBackgroundRadiusTop()286     public float getCurrentBackgroundRadiusTop() {
287         // If this view is top amount notification view, it should always has round corners on top.
288         // It will be applied with applyRoundness()
289         if (mTopAmountRounded) {
290             return mOutlineRadius;
291         }
292         return mCurrentTopRoundness * mOutlineRadius;
293     }
294 
getCurrentTopRoundness()295     public float getCurrentTopRoundness() {
296         return mCurrentTopRoundness;
297     }
298 
getCurrentBottomRoundness()299     public float getCurrentBottomRoundness() {
300         return mCurrentBottomRoundness;
301     }
302 
getCurrentBackgroundRadiusBottom()303     protected float getCurrentBackgroundRadiusBottom() {
304         return mCurrentBottomRoundness * mOutlineRadius;
305     }
306 
307     /**
308      * Set the bottom roundness of this view.
309      * @return Whether the roundness was changed.
310      */
setBottomRoundness(float bottomRoundness, boolean animate)311     public boolean setBottomRoundness(float bottomRoundness, boolean animate) {
312         if (mBottomRoundness != bottomRoundness) {
313             mBottomRoundness = bottomRoundness;
314             PropertyAnimator.setProperty(this, BOTTOM_ROUNDNESS, bottomRoundness,
315                     ROUNDNESS_PROPERTIES, animate);
316             return true;
317         }
318         return false;
319     }
320 
setBackgroundTop(int backgroundTop)321     protected void setBackgroundTop(int backgroundTop) {
322         if (mBackgroundTop != backgroundTop) {
323             mBackgroundTop = backgroundTop;
324             invalidateOutline();
325         }
326     }
327 
setTopRoundnessInternal(float topRoundness)328     private void setTopRoundnessInternal(float topRoundness) {
329         mCurrentTopRoundness = topRoundness;
330         applyRoundness();
331     }
332 
setBottomRoundnessInternal(float bottomRoundness)333     private void setBottomRoundnessInternal(float bottomRoundness) {
334         mCurrentBottomRoundness = bottomRoundness;
335         applyRoundness();
336     }
337 
onDensityOrFontScaleChanged()338     public void onDensityOrFontScaleChanged() {
339         initDimens();
340         applyRoundness();
341     }
342 
343     @Override
setActualHeight(int actualHeight, boolean notifyListeners)344     public void setActualHeight(int actualHeight, boolean notifyListeners) {
345         int previousHeight = getActualHeight();
346         super.setActualHeight(actualHeight, notifyListeners);
347         if (previousHeight != actualHeight) {
348             applyRoundness();
349         }
350     }
351 
352     @Override
setClipTopAmount(int clipTopAmount)353     public void setClipTopAmount(int clipTopAmount) {
354         int previousAmount = getClipTopAmount();
355         super.setClipTopAmount(clipTopAmount);
356         if (previousAmount != clipTopAmount) {
357             applyRoundness();
358         }
359     }
360 
361     @Override
setClipBottomAmount(int clipBottomAmount)362     public void setClipBottomAmount(int clipBottomAmount) {
363         int previousAmount = getClipBottomAmount();
364         super.setClipBottomAmount(clipBottomAmount);
365         if (previousAmount != clipBottomAmount) {
366             applyRoundness();
367         }
368     }
369 
setOutlineAlpha(float alpha)370     protected void setOutlineAlpha(float alpha) {
371         if (alpha != mOutlineAlpha) {
372             mOutlineAlpha = alpha;
373             applyRoundness();
374         }
375     }
376 
377     @Override
getOutlineAlpha()378     public float getOutlineAlpha() {
379         return mOutlineAlpha;
380     }
381 
setOutlineRect(RectF rect)382     protected void setOutlineRect(RectF rect) {
383         if (rect != null) {
384             setOutlineRect(rect.left, rect.top, rect.right, rect.bottom);
385         } else {
386             mCustomOutline = false;
387             applyRoundness();
388         }
389     }
390 
391     @Override
getOutlineTranslation()392     public int getOutlineTranslation() {
393         return mCustomOutline ? mOutlineRect.left : (int) getTranslation();
394     }
395 
updateOutline()396     public void updateOutline() {
397         if (mCustomOutline) {
398             return;
399         }
400         boolean hasOutline = needsOutline();
401         setOutlineProvider(hasOutline ? mProvider : null);
402     }
403 
404     /**
405      * @return Whether the view currently needs an outline. This is usually {@code false} in case
406      * it doesn't have a background.
407      */
needsOutline()408     protected boolean needsOutline() {
409         if (isChildInGroup()) {
410             return isGroupExpanded() && !isGroupExpansionChanging();
411         } else if (isSummaryWithChildren()) {
412             return !isGroupExpanded() || isGroupExpansionChanging();
413         }
414         return true;
415     }
416 
isOutlineShowing()417     public boolean isOutlineShowing() {
418         ViewOutlineProvider op = getOutlineProvider();
419         return op != null;
420     }
421 
setOutlineRect(float left, float top, float right, float bottom)422     protected void setOutlineRect(float left, float top, float right, float bottom) {
423         mCustomOutline = true;
424 
425         mOutlineRect.set((int) left, (int) top, (int) right, (int) bottom);
426 
427         // Outlines need to be at least 1 dp
428         mOutlineRect.bottom = (int) Math.max(top, mOutlineRect.bottom);
429         mOutlineRect.right = (int) Math.max(left, mOutlineRect.right);
430         applyRoundness();
431     }
432 
getCustomClipPath(View child)433     public Path getCustomClipPath(View child) {
434         return null;
435     }
436 }
437