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