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.systemui.R; 31 import com.android.systemui.statusbar.notification.AnimatableProperty; 32 import com.android.systemui.statusbar.notification.PropertyAnimator; 33 import com.android.systemui.statusbar.notification.stack.AnimationProperties; 34 import com.android.systemui.statusbar.notification.stack.StackStateAnimator; 35 36 /** 37 * Like {@link ExpandableView}, but setting an outline for the height and clipping. 38 */ 39 public abstract class ExpandableOutlineView extends ExpandableView { 40 41 private static final AnimatableProperty TOP_ROUNDNESS = AnimatableProperty.from( 42 "topRoundness", 43 ExpandableOutlineView::setTopRoundnessInternal, 44 ExpandableOutlineView::getCurrentTopRoundness, 45 R.id.top_roundess_animator_tag, 46 R.id.top_roundess_animator_end_tag, 47 R.id.top_roundess_animator_start_tag); 48 private static final AnimatableProperty BOTTOM_ROUNDNESS = AnimatableProperty.from( 49 "bottomRoundness", 50 ExpandableOutlineView::setBottomRoundnessInternal, 51 ExpandableOutlineView::getCurrentBottomRoundness, 52 R.id.bottom_roundess_animator_tag, 53 R.id.bottom_roundess_animator_end_tag, 54 R.id.bottom_roundess_animator_start_tag); 55 private static final AnimationProperties ROUNDNESS_PROPERTIES = 56 new AnimationProperties().setDuration( 57 StackStateAnimator.ANIMATION_DURATION_CORNER_RADIUS); 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 false} 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 mDismissUsingRowTranslationX = true; 78 private float[] mTmpCornerRadii = new float[8]; 79 80 private final ViewOutlineProvider mProvider = new ViewOutlineProvider() { 81 @Override 82 public void getOutline(View view, Outline outline) { 83 if (!mCustomOutline && getCurrentTopRoundness() == 0.0f 84 && getCurrentBottomRoundness() == 0.0f && !mAlwaysRoundBothCorners) { 85 // Only when translating just the contents, does the outline need to be shifted. 86 int translation = !mDismissUsingRowTranslationX ? (int) getTranslation() : 0; 87 int left = Math.max(translation, 0); 88 int top = mClipTopAmount + mBackgroundTop; 89 int right = getWidth() + Math.min(translation, 0); 90 int bottom = Math.max(getActualHeight() - mClipBottomAmount, top); 91 outline.setRect(left, top, right, bottom); 92 } else { 93 Path clipPath = getClipPath(false /* ignoreTranslation */); 94 if (clipPath != null) { 95 outline.setPath(clipPath); 96 } 97 } 98 outline.setAlpha(mOutlineAlpha); 99 } 100 }; 101 getClipPath(boolean ignoreTranslation)102 protected Path getClipPath(boolean ignoreTranslation) { 103 int left; 104 int top; 105 int right; 106 int bottom; 107 int height; 108 float topRoundness = mAlwaysRoundBothCorners 109 ? mOutlineRadius : getCurrentBackgroundRadiusTop(); 110 if (!mCustomOutline) { 111 // The outline just needs to be shifted if we're translating the contents. Otherwise 112 // it's already in the right place. 113 int translation = !mDismissUsingRowTranslationX && !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 float currentTopRoundness = getCurrentTopRoundness(); 138 float currentBottomRoundness = getCurrentBottomRoundness(); 139 topRoundness -= overShoot * currentTopRoundness 140 / (currentTopRoundness + currentBottomRoundness); 141 bottomRoundness -= overShoot * currentBottomRoundness 142 / (currentTopRoundness + currentBottomRoundness); 143 } 144 getRoundedRectPath(left, top, right, bottom, topRoundness, bottomRoundness, mTmpPath); 145 return mTmpPath; 146 } 147 getRoundedRectPath(int left, int top, int right, int bottom, float topRoundness, float bottomRoundness, Path outPath)148 public void getRoundedRectPath(int left, int top, int right, int bottom, 149 float topRoundness, float bottomRoundness, Path outPath) { 150 outPath.reset(); 151 mTmpCornerRadii[0] = topRoundness; 152 mTmpCornerRadii[1] = topRoundness; 153 mTmpCornerRadii[2] = topRoundness; 154 mTmpCornerRadii[3] = topRoundness; 155 mTmpCornerRadii[4] = bottomRoundness; 156 mTmpCornerRadii[5] = bottomRoundness; 157 mTmpCornerRadii[6] = bottomRoundness; 158 mTmpCornerRadii[7] = bottomRoundness; 159 outPath.addRoundRect(left, top, right, bottom, mTmpCornerRadii, Path.Direction.CW); 160 } 161 ExpandableOutlineView(Context context, AttributeSet attrs)162 public ExpandableOutlineView(Context context, AttributeSet attrs) { 163 super(context, attrs); 164 setOutlineProvider(mProvider); 165 initDimens(); 166 } 167 168 @Override drawChild(Canvas canvas, View child, long drawingTime)169 protected boolean drawChild(Canvas canvas, View child, long drawingTime) { 170 canvas.save(); 171 if (childNeedsClipping(child)) { 172 Path clipPath = getCustomClipPath(child); 173 if (clipPath == null) { 174 clipPath = getClipPath(false /* ignoreTranslation */); 175 } 176 if (clipPath != null) { 177 canvas.clipPath(clipPath); 178 } 179 } 180 boolean result = super.drawChild(canvas, child, drawingTime); 181 canvas.restore(); 182 return result; 183 } 184 185 @Override setExtraWidthForClipping(float extraWidthForClipping)186 public void setExtraWidthForClipping(float extraWidthForClipping) { 187 super.setExtraWidthForClipping(extraWidthForClipping); 188 invalidate(); 189 } 190 191 @Override setMinimumHeightForClipping(int minimumHeightForClipping)192 public void setMinimumHeightForClipping(int minimumHeightForClipping) { 193 super.setMinimumHeightForClipping(minimumHeightForClipping); 194 invalidate(); 195 } 196 childNeedsClipping(View child)197 protected boolean childNeedsClipping(View child) { 198 return false; 199 } 200 isClippingNeeded()201 protected boolean isClippingNeeded() { 202 // When translating the contents instead of the overall view, we need to make sure we clip 203 // rounded to the contents. 204 boolean forTranslation = getTranslation() != 0 && !mDismissUsingRowTranslationX; 205 return mAlwaysRoundBothCorners || mCustomOutline || forTranslation; 206 } 207 initDimens()208 private void initDimens() { 209 Resources res = getResources(); 210 mOutlineRadius = res.getDimension(R.dimen.notification_shadow_radius); 211 mAlwaysRoundBothCorners = res.getBoolean(R.bool.config_clipNotificationsToOutline); 212 if (!mAlwaysRoundBothCorners) { 213 mOutlineRadius = res.getDimensionPixelSize(R.dimen.notification_corner_radius); 214 } 215 setClipToOutline(mAlwaysRoundBothCorners); 216 } 217 218 @Override setTopRoundness(float topRoundness, boolean animate)219 public boolean setTopRoundness(float topRoundness, boolean animate) { 220 if (mTopRoundness != topRoundness) { 221 float diff = Math.abs(topRoundness - mTopRoundness); 222 mTopRoundness = topRoundness; 223 boolean shouldAnimate = animate; 224 if (PropertyAnimator.isAnimating(this, TOP_ROUNDNESS) && diff > 0.5f) { 225 // Fail safe: 226 // when we've been animating previously and we're now getting an update in the 227 // other direction, make sure to animate it too, otherwise, the localized updating 228 // may make the start larger than 1.0. 229 shouldAnimate = true; 230 } 231 PropertyAnimator.setProperty(this, TOP_ROUNDNESS, topRoundness, 232 ROUNDNESS_PROPERTIES, shouldAnimate); 233 return true; 234 } 235 return false; 236 } 237 applyRoundness()238 protected void applyRoundness() { 239 invalidateOutline(); 240 invalidate(); 241 } 242 getCurrentBackgroundRadiusTop()243 public float getCurrentBackgroundRadiusTop() { 244 return getCurrentTopRoundness() * mOutlineRadius; 245 } 246 getCurrentTopRoundness()247 public float getCurrentTopRoundness() { 248 return mCurrentTopRoundness; 249 } 250 getCurrentBottomRoundness()251 public float getCurrentBottomRoundness() { 252 return mCurrentBottomRoundness; 253 } 254 getCurrentBackgroundRadiusBottom()255 public float getCurrentBackgroundRadiusBottom() { 256 return getCurrentBottomRoundness() * mOutlineRadius; 257 } 258 259 @Override setBottomRoundness(float bottomRoundness, boolean animate)260 public boolean setBottomRoundness(float bottomRoundness, boolean animate) { 261 if (mBottomRoundness != bottomRoundness) { 262 float diff = Math.abs(bottomRoundness - mBottomRoundness); 263 mBottomRoundness = bottomRoundness; 264 boolean shouldAnimate = animate; 265 if (PropertyAnimator.isAnimating(this, BOTTOM_ROUNDNESS) && diff > 0.5f) { 266 // Fail safe: 267 // when we've been animating previously and we're now getting an update in the 268 // other direction, make sure to animate it too, otherwise, the localized updating 269 // may make the start larger than 1.0. 270 shouldAnimate = true; 271 } 272 PropertyAnimator.setProperty(this, BOTTOM_ROUNDNESS, bottomRoundness, 273 ROUNDNESS_PROPERTIES, shouldAnimate); 274 return true; 275 } 276 return false; 277 } 278 setBackgroundTop(int backgroundTop)279 protected void setBackgroundTop(int backgroundTop) { 280 if (mBackgroundTop != backgroundTop) { 281 mBackgroundTop = backgroundTop; 282 invalidateOutline(); 283 } 284 } 285 setTopRoundnessInternal(float topRoundness)286 private void setTopRoundnessInternal(float topRoundness) { 287 mCurrentTopRoundness = topRoundness; 288 applyRoundness(); 289 } 290 setBottomRoundnessInternal(float bottomRoundness)291 private void setBottomRoundnessInternal(float bottomRoundness) { 292 mCurrentBottomRoundness = bottomRoundness; 293 applyRoundness(); 294 } 295 onDensityOrFontScaleChanged()296 public void onDensityOrFontScaleChanged() { 297 initDimens(); 298 applyRoundness(); 299 } 300 301 @Override setActualHeight(int actualHeight, boolean notifyListeners)302 public void setActualHeight(int actualHeight, boolean notifyListeners) { 303 int previousHeight = getActualHeight(); 304 super.setActualHeight(actualHeight, notifyListeners); 305 if (previousHeight != actualHeight) { 306 applyRoundness(); 307 } 308 } 309 310 @Override setClipTopAmount(int clipTopAmount)311 public void setClipTopAmount(int clipTopAmount) { 312 int previousAmount = getClipTopAmount(); 313 super.setClipTopAmount(clipTopAmount); 314 if (previousAmount != clipTopAmount) { 315 applyRoundness(); 316 } 317 } 318 319 @Override setClipBottomAmount(int clipBottomAmount)320 public void setClipBottomAmount(int clipBottomAmount) { 321 int previousAmount = getClipBottomAmount(); 322 super.setClipBottomAmount(clipBottomAmount); 323 if (previousAmount != clipBottomAmount) { 324 applyRoundness(); 325 } 326 } 327 setOutlineAlpha(float alpha)328 protected void setOutlineAlpha(float alpha) { 329 if (alpha != mOutlineAlpha) { 330 mOutlineAlpha = alpha; 331 applyRoundness(); 332 } 333 } 334 335 @Override getOutlineAlpha()336 public float getOutlineAlpha() { 337 return mOutlineAlpha; 338 } 339 setOutlineRect(RectF rect)340 protected void setOutlineRect(RectF rect) { 341 if (rect != null) { 342 setOutlineRect(rect.left, rect.top, rect.right, rect.bottom); 343 } else { 344 mCustomOutline = false; 345 applyRoundness(); 346 } 347 } 348 349 /** 350 * Set the dismiss behavior of the view. 351 * @param usingRowTranslationX {@code true} if the view should translate using regular 352 * translationX, otherwise the contents will be 353 * translated. 354 */ setDismissUsingRowTranslationX(boolean usingRowTranslationX)355 public void setDismissUsingRowTranslationX(boolean usingRowTranslationX) { 356 mDismissUsingRowTranslationX = usingRowTranslationX; 357 } 358 359 @Override getOutlineTranslation()360 public int getOutlineTranslation() { 361 if (mCustomOutline) { 362 return mOutlineRect.left; 363 } 364 if (mDismissUsingRowTranslationX) { 365 return 0; 366 } 367 return (int) getTranslation(); 368 } 369 updateOutline()370 public void updateOutline() { 371 if (mCustomOutline) { 372 return; 373 } 374 boolean hasOutline = needsOutline(); 375 setOutlineProvider(hasOutline ? mProvider : null); 376 } 377 378 /** 379 * @return Whether the view currently needs an outline. This is usually {@code false} in case 380 * it doesn't have a background. 381 */ needsOutline()382 protected boolean needsOutline() { 383 if (isChildInGroup()) { 384 return isGroupExpanded() && !isGroupExpansionChanging(); 385 } else if (isSummaryWithChildren()) { 386 return !isGroupExpanded() || isGroupExpansionChanging(); 387 } 388 return true; 389 } 390 isOutlineShowing()391 public boolean isOutlineShowing() { 392 ViewOutlineProvider op = getOutlineProvider(); 393 return op != null; 394 } 395 setOutlineRect(float left, float top, float right, float bottom)396 protected void setOutlineRect(float left, float top, float right, float bottom) { 397 mCustomOutline = true; 398 399 mOutlineRect.set((int) left, (int) top, (int) right, (int) bottom); 400 401 // Outlines need to be at least 1 dp 402 mOutlineRect.bottom = (int) Math.max(top, mOutlineRect.bottom); 403 mOutlineRect.right = (int) Math.max(left, mOutlineRect.right); 404 applyRoundness(); 405 } 406 getCustomClipPath(View child)407 public Path getCustomClipPath(View child) { 408 return null; 409 } 410 } 411