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 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