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 getClipPath()108 private Path getClipPath() { 109 return getClipPath(false, /* ignoreTranslation */ 110 false /* clipRoundedToBottom */); 111 } 112 getClipPath(boolean ignoreTranslation, boolean clipRoundedToBottom)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 getRoundedRectPath(int left, int top, int right, int bottom, float topRoundness, float bottomRoundness, Path outPath)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 ExpandableOutlineView(Context context, AttributeSet attrs)197 public ExpandableOutlineView(Context context, AttributeSet attrs) { 198 super(context, attrs); 199 setOutlineProvider(mProvider); 200 initDimens(); 201 } 202 203 @Override drawChild(Canvas canvas, View child, long drawingTime)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 setExtraWidthForClipping(float extraWidthForClipping)240 public void setExtraWidthForClipping(float extraWidthForClipping) { 241 mExtraWidthForClipping = extraWidthForClipping; 242 } 243 setMinimumHeightForClipping(int minimumHeightForClipping)244 public void setMinimumHeightForClipping(int minimumHeightForClipping) { 245 mMinimumHeightForClipping = minimumHeightForClipping; 246 } 247 248 @Override setDistanceToTopRoundness(float distanceToTopRoundness)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 childNeedsClipping(View child)258 protected boolean childNeedsClipping(View child) { 259 return false; 260 } 261 topAmountNeedsClipping()262 public boolean topAmountNeedsClipping() { 263 return true; 264 } 265 isClippingNeeded()266 protected boolean isClippingNeeded() { 267 return mAlwaysRoundBothCorners || mCustomOutline || getTranslation() != 0 ; 268 } 269 initDimens()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 */ setTopRoundness(float topRoundness, boolean animate)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 applyRoundness()297 protected void applyRoundness() { 298 invalidateOutline(); 299 invalidate(); 300 } 301 getCurrentBackgroundRadiusTop()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 getCurrentTopRoundness()311 public float getCurrentTopRoundness() { 312 return mCurrentTopRoundness; 313 } 314 getCurrentBottomRoundness()315 public float getCurrentBottomRoundness() { 316 return mCurrentBottomRoundness; 317 } 318 getCurrentBackgroundRadiusBottom()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 */ setBottomRoundness(float bottomRoundness, boolean animate)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 setBackgroundTop(int backgroundTop)337 protected void setBackgroundTop(int backgroundTop) { 338 if (mBackgroundTop != backgroundTop) { 339 mBackgroundTop = backgroundTop; 340 invalidateOutline(); 341 } 342 } 343 setTopRoundnessInternal(float topRoundness)344 private void setTopRoundnessInternal(float topRoundness) { 345 mCurrentTopRoundness = topRoundness; 346 applyRoundness(); 347 } 348 setBottomRoundnessInternal(float bottomRoundness)349 private void setBottomRoundnessInternal(float bottomRoundness) { 350 mCurrentBottomRoundness = bottomRoundness; 351 applyRoundness(); 352 } 353 onDensityOrFontScaleChanged()354 public void onDensityOrFontScaleChanged() { 355 initDimens(); 356 applyRoundness(); 357 } 358 359 @Override setActualHeight(int actualHeight, boolean notifyListeners)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 setClipTopAmount(int clipTopAmount)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 setClipBottomAmount(int clipBottomAmount)378 public void setClipBottomAmount(int clipBottomAmount) { 379 int previousAmount = getClipBottomAmount(); 380 super.setClipBottomAmount(clipBottomAmount); 381 if (previousAmount != clipBottomAmount) { 382 applyRoundness(); 383 } 384 } 385 setOutlineAlpha(float alpha)386 protected void setOutlineAlpha(float alpha) { 387 if (alpha != mOutlineAlpha) { 388 mOutlineAlpha = alpha; 389 applyRoundness(); 390 } 391 } 392 393 @Override getOutlineAlpha()394 public float getOutlineAlpha() { 395 return mOutlineAlpha; 396 } 397 setOutlineRect(RectF rect)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 getOutlineTranslation()408 public int getOutlineTranslation() { 409 return mCustomOutline ? mOutlineRect.left : (int) getTranslation(); 410 } 411 updateOutline()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 */ needsOutline()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 isOutlineShowing()433 public boolean isOutlineShowing() { 434 ViewOutlineProvider op = getOutlineProvider(); 435 return op != null; 436 } 437 setOutlineRect(float left, float top, float right, float bottom)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 getCustomClipPath(View child)449 public Path getCustomClipPath(View child) { 450 return null; 451 } 452 } 453