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
17package com.android.systemui.statusbar;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Canvas;
22import android.graphics.Outline;
23import android.graphics.Path;
24import android.graphics.Rect;
25import android.graphics.RectF;
26import android.util.AttributeSet;
27import android.view.View;
28import android.view.ViewOutlineProvider;
29
30import com.android.settingslib.Utils;
31import com.android.systemui.R;
32import com.android.systemui.statusbar.notification.AnimatableProperty;
33import com.android.systemui.statusbar.notification.PropertyAnimator;
34import com.android.systemui.statusbar.stack.AnimationProperties;
35import com.android.systemui.statusbar.stack.StackStateAnimator;
36
37/**
38 * Like {@link ExpandableView}, but setting an outline for the height and clipping.
39 */
40public 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
108    private Path getClipPath() {
109        return getClipPath(false, /* ignoreTranslation */
110                false /* clipRoundedToBottom */);
111    }
112
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
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
197    public ExpandableOutlineView(Context context, AttributeSet attrs) {
198        super(context, attrs);
199        setOutlineProvider(mProvider);
200        initDimens();
201    }
202
203    @Override
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
240    public void setExtraWidthForClipping(float extraWidthForClipping) {
241        mExtraWidthForClipping = extraWidthForClipping;
242    }
243
244    public void setMinimumHeightForClipping(int minimumHeightForClipping) {
245        mMinimumHeightForClipping = minimumHeightForClipping;
246    }
247
248    @Override
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
258    protected boolean childNeedsClipping(View child) {
259        return false;
260    }
261
262    public boolean topAmountNeedsClipping() {
263        return true;
264    }
265
266    protected boolean isClippingNeeded() {
267        return mAlwaysRoundBothCorners || mCustomOutline || getTranslation() != 0 ;
268    }
269
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     */
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
297    protected void applyRoundness() {
298        invalidateOutline();
299        invalidate();
300    }
301
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
311    public float getCurrentTopRoundness() {
312        return mCurrentTopRoundness;
313    }
314
315    public float getCurrentBottomRoundness() {
316        return mCurrentBottomRoundness;
317    }
318
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     */
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
337    protected void setBackgroundTop(int backgroundTop) {
338        if (mBackgroundTop != backgroundTop) {
339            mBackgroundTop = backgroundTop;
340            invalidateOutline();
341        }
342    }
343
344    private void setTopRoundnessInternal(float topRoundness) {
345        mCurrentTopRoundness = topRoundness;
346        applyRoundness();
347    }
348
349    private void setBottomRoundnessInternal(float bottomRoundness) {
350        mCurrentBottomRoundness = bottomRoundness;
351        applyRoundness();
352    }
353
354    public void onDensityOrFontScaleChanged() {
355        initDimens();
356        applyRoundness();
357    }
358
359    @Override
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
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
378    public void setClipBottomAmount(int clipBottomAmount) {
379        int previousAmount = getClipBottomAmount();
380        super.setClipBottomAmount(clipBottomAmount);
381        if (previousAmount != clipBottomAmount) {
382            applyRoundness();
383        }
384    }
385
386    protected void setOutlineAlpha(float alpha) {
387        if (alpha != mOutlineAlpha) {
388            mOutlineAlpha = alpha;
389            applyRoundness();
390        }
391    }
392
393    @Override
394    public float getOutlineAlpha() {
395        return mOutlineAlpha;
396    }
397
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
408    public int getOutlineTranslation() {
409        return mCustomOutline ? mOutlineRect.left : (int) getTranslation();
410    }
411
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     */
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
433    public boolean isOutlineShowing() {
434        ViewOutlineProvider op = getOutlineProvider();
435        return op != null;
436    }
437
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
449    public Path getCustomClipPath(View child) {
450        return null;
451    }
452}
453