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