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