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.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.notification.stack.AnimationProperties; 35 import com.android.systemui.statusbar.notification.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 float mCurrentBottomRoundness; 68 private float mCurrentTopRoundness; 69 private float mBottomRoundness; 70 private float mTopRoundness; 71 private int mBackgroundTop; 72 73 /** 74 * {@code true} 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 mShouldTranslateContents; 78 private boolean mTopAmountRounded; 79 private float mDistanceToTopRoundness = -1; 80 81 private final ViewOutlineProvider mProvider = new ViewOutlineProvider() { 82 @Override 83 public void getOutline(View view, Outline outline) { 84 if (!mCustomOutline && mCurrentTopRoundness == 0.0f 85 && mCurrentBottomRoundness == 0.0f && !mAlwaysRoundBothCorners 86 && !mTopAmountRounded) { 87 int translation = mShouldTranslateContents ? (int) getTranslation() : 0; 88 int left = Math.max(translation, 0); 89 int top = mClipTopAmount + mBackgroundTop; 90 int right = getWidth() + Math.min(translation, 0); 91 int bottom = Math.max(getActualHeight() - mClipBottomAmount, top); 92 outline.setRect(left, top, right, bottom); 93 } else { 94 Path clipPath = getClipPath(false /* ignoreTranslation */); 95 if (clipPath != null && clipPath.isConvex()) { 96 // The path might not be convex in border cases where the view is small and 97 // clipped 98 outline.setConvexPath(clipPath); 99 } 100 } 101 outline.setAlpha(mOutlineAlpha); 102 } 103 }; 104 getClipPath(boolean ignoreTranslation)105 protected Path getClipPath(boolean ignoreTranslation) { 106 int left; 107 int top; 108 int right; 109 int bottom; 110 int height; 111 float topRoundness = mAlwaysRoundBothCorners 112 ? mOutlineRadius : getCurrentBackgroundRadiusTop(); 113 if (!mCustomOutline) { 114 int translation = mShouldTranslateContents && !ignoreTranslation 115 ? (int) getTranslation() : 0; 116 int halfExtraWidth = (int) (mExtraWidthForClipping / 2.0f); 117 left = Math.max(translation, 0) - halfExtraWidth; 118 top = mClipTopAmount + mBackgroundTop; 119 right = getWidth() + halfExtraWidth + Math.min(translation, 0); 120 // If the top is rounded we want the bottom to be at most at the top roundness, in order 121 // to avoid the shadow changing when scrolling up. 122 bottom = Math.max(mMinimumHeightForClipping, 123 Math.max(getActualHeight() - mClipBottomAmount, (int) (top + topRoundness))); 124 } else { 125 left = mOutlineRect.left; 126 top = mOutlineRect.top; 127 right = mOutlineRect.right; 128 bottom = mOutlineRect.bottom; 129 } 130 height = bottom - top; 131 if (height == 0) { 132 return EMPTY_PATH; 133 } 134 float bottomRoundness = mAlwaysRoundBothCorners 135 ? mOutlineRadius : getCurrentBackgroundRadiusBottom(); 136 if (topRoundness + bottomRoundness > height) { 137 float overShoot = topRoundness + bottomRoundness - height; 138 topRoundness -= overShoot * mCurrentTopRoundness 139 / (mCurrentTopRoundness + mCurrentBottomRoundness); 140 bottomRoundness -= overShoot * mCurrentBottomRoundness 141 / (mCurrentTopRoundness + mCurrentBottomRoundness); 142 } 143 getRoundedRectPath(left, top, right, bottom, topRoundness, 144 bottomRoundness, mTmpPath); 145 return mTmpPath; 146 } 147 getRoundedRectPath(int left, int top, int right, int bottom, float topRoundness, float bottomRoundness, Path outPath)148 public static void getRoundedRectPath(int left, int top, int right, int bottom, 149 float topRoundness, float bottomRoundness, Path outPath) { 150 outPath.reset(); 151 int width = right - left; 152 float topRoundnessX = topRoundness; 153 float bottomRoundnessX = bottomRoundness; 154 topRoundnessX = Math.min(width / 2, topRoundnessX); 155 bottomRoundnessX = Math.min(width / 2, bottomRoundnessX); 156 if (topRoundness > 0.0f) { 157 outPath.moveTo(left, top + topRoundness); 158 outPath.quadTo(left, top, left + topRoundnessX, top); 159 outPath.lineTo(right - topRoundnessX, top); 160 outPath.quadTo(right, top, right, top + topRoundness); 161 } else { 162 outPath.moveTo(left, top); 163 outPath.lineTo(right, top); 164 } 165 if (bottomRoundness > 0.0f) { 166 outPath.lineTo(right, bottom - bottomRoundness); 167 outPath.quadTo(right, bottom, right - bottomRoundnessX, bottom); 168 outPath.lineTo(left + bottomRoundnessX, bottom); 169 outPath.quadTo(left, bottom, left, bottom - bottomRoundness); 170 } else { 171 outPath.lineTo(right, bottom); 172 outPath.lineTo(left, bottom); 173 } 174 outPath.close(); 175 } 176 ExpandableOutlineView(Context context, AttributeSet attrs)177 public ExpandableOutlineView(Context context, AttributeSet attrs) { 178 super(context, attrs); 179 setOutlineProvider(mProvider); 180 initDimens(); 181 } 182 183 @Override drawChild(Canvas canvas, View child, long drawingTime)184 protected boolean drawChild(Canvas canvas, View child, long drawingTime) { 185 canvas.save(); 186 Path intersectPath = null; 187 if (mTopAmountRounded && topAmountNeedsClipping()) { 188 int left = (int) (- mExtraWidthForClipping / 2.0f); 189 int top = (int) (mClipTopAmount - mDistanceToTopRoundness); 190 int right = getWidth() + (int) (mExtraWidthForClipping + left); 191 int bottom = (int) Math.max(mMinimumHeightForClipping, 192 Math.max(getActualHeight() - mClipBottomAmount, top + mOutlineRadius)); 193 ExpandableOutlineView.getRoundedRectPath(left, top, right, bottom, mOutlineRadius, 194 0.0f, 195 mClipPath); 196 intersectPath = mClipPath; 197 } 198 boolean clipped = false; 199 if (childNeedsClipping(child)) { 200 Path clipPath = getCustomClipPath(child); 201 if (clipPath == null) { 202 clipPath = getClipPath(false /* ignoreTranslation */); 203 } 204 if (clipPath != null) { 205 if (intersectPath != null) { 206 clipPath.op(intersectPath, Path.Op.INTERSECT); 207 } 208 canvas.clipPath(clipPath); 209 clipped = true; 210 } 211 } 212 if (!clipped && intersectPath != null) { 213 canvas.clipPath(intersectPath); 214 } 215 boolean result = super.drawChild(canvas, child, drawingTime); 216 canvas.restore(); 217 return result; 218 } 219 220 @Override setExtraWidthForClipping(float extraWidthForClipping)221 public void setExtraWidthForClipping(float extraWidthForClipping) { 222 super.setExtraWidthForClipping(extraWidthForClipping); 223 invalidate(); 224 } 225 226 @Override setMinimumHeightForClipping(int minimumHeightForClipping)227 public void setMinimumHeightForClipping(int minimumHeightForClipping) { 228 super.setMinimumHeightForClipping(minimumHeightForClipping); 229 invalidate(); 230 } 231 232 @Override setDistanceToTopRoundness(float distanceToTopRoundness)233 public void setDistanceToTopRoundness(float distanceToTopRoundness) { 234 super.setDistanceToTopRoundness(distanceToTopRoundness); 235 if (distanceToTopRoundness != mDistanceToTopRoundness) { 236 mTopAmountRounded = distanceToTopRoundness >= 0; 237 mDistanceToTopRoundness = distanceToTopRoundness; 238 applyRoundness(); 239 } 240 } 241 childNeedsClipping(View child)242 protected boolean childNeedsClipping(View child) { 243 return false; 244 } 245 topAmountNeedsClipping()246 public boolean topAmountNeedsClipping() { 247 return true; 248 } 249 isClippingNeeded()250 protected boolean isClippingNeeded() { 251 return mAlwaysRoundBothCorners || mCustomOutline || getTranslation() != 0 ; 252 } 253 initDimens()254 private void initDimens() { 255 Resources res = getResources(); 256 mShouldTranslateContents = 257 res.getBoolean(R.bool.config_translateNotificationContentsOnSwipe); 258 mOutlineRadius = res.getDimension(R.dimen.notification_shadow_radius); 259 mAlwaysRoundBothCorners = res.getBoolean(R.bool.config_clipNotificationsToOutline); 260 if (!mAlwaysRoundBothCorners) { 261 mOutlineRadius = res.getDimensionPixelSize( 262 Utils.getThemeAttr(mContext, android.R.attr.dialogCornerRadius)); 263 } 264 setClipToOutline(mAlwaysRoundBothCorners); 265 } 266 267 /** 268 * Set the topRoundness of this view. 269 * @return Whether the roundness was changed. 270 */ setTopRoundness(float topRoundness, boolean animate)271 public boolean setTopRoundness(float topRoundness, boolean animate) { 272 if (mTopRoundness != topRoundness) { 273 mTopRoundness = topRoundness; 274 PropertyAnimator.setProperty(this, TOP_ROUNDNESS, topRoundness, 275 ROUNDNESS_PROPERTIES, animate); 276 return true; 277 } 278 return false; 279 } 280 applyRoundness()281 protected void applyRoundness() { 282 invalidateOutline(); 283 invalidate(); 284 } 285 getCurrentBackgroundRadiusTop()286 public float getCurrentBackgroundRadiusTop() { 287 // If this view is top amount notification view, it should always has round corners on top. 288 // It will be applied with applyRoundness() 289 if (mTopAmountRounded) { 290 return mOutlineRadius; 291 } 292 return mCurrentTopRoundness * mOutlineRadius; 293 } 294 getCurrentTopRoundness()295 public float getCurrentTopRoundness() { 296 return mCurrentTopRoundness; 297 } 298 getCurrentBottomRoundness()299 public float getCurrentBottomRoundness() { 300 return mCurrentBottomRoundness; 301 } 302 getCurrentBackgroundRadiusBottom()303 protected float getCurrentBackgroundRadiusBottom() { 304 return mCurrentBottomRoundness * mOutlineRadius; 305 } 306 307 /** 308 * Set the bottom roundness of this view. 309 * @return Whether the roundness was changed. 310 */ setBottomRoundness(float bottomRoundness, boolean animate)311 public boolean setBottomRoundness(float bottomRoundness, boolean animate) { 312 if (mBottomRoundness != bottomRoundness) { 313 mBottomRoundness = bottomRoundness; 314 PropertyAnimator.setProperty(this, BOTTOM_ROUNDNESS, bottomRoundness, 315 ROUNDNESS_PROPERTIES, animate); 316 return true; 317 } 318 return false; 319 } 320 setBackgroundTop(int backgroundTop)321 protected void setBackgroundTop(int backgroundTop) { 322 if (mBackgroundTop != backgroundTop) { 323 mBackgroundTop = backgroundTop; 324 invalidateOutline(); 325 } 326 } 327 setTopRoundnessInternal(float topRoundness)328 private void setTopRoundnessInternal(float topRoundness) { 329 mCurrentTopRoundness = topRoundness; 330 applyRoundness(); 331 } 332 setBottomRoundnessInternal(float bottomRoundness)333 private void setBottomRoundnessInternal(float bottomRoundness) { 334 mCurrentBottomRoundness = bottomRoundness; 335 applyRoundness(); 336 } 337 onDensityOrFontScaleChanged()338 public void onDensityOrFontScaleChanged() { 339 initDimens(); 340 applyRoundness(); 341 } 342 343 @Override setActualHeight(int actualHeight, boolean notifyListeners)344 public void setActualHeight(int actualHeight, boolean notifyListeners) { 345 int previousHeight = getActualHeight(); 346 super.setActualHeight(actualHeight, notifyListeners); 347 if (previousHeight != actualHeight) { 348 applyRoundness(); 349 } 350 } 351 352 @Override setClipTopAmount(int clipTopAmount)353 public void setClipTopAmount(int clipTopAmount) { 354 int previousAmount = getClipTopAmount(); 355 super.setClipTopAmount(clipTopAmount); 356 if (previousAmount != clipTopAmount) { 357 applyRoundness(); 358 } 359 } 360 361 @Override setClipBottomAmount(int clipBottomAmount)362 public void setClipBottomAmount(int clipBottomAmount) { 363 int previousAmount = getClipBottomAmount(); 364 super.setClipBottomAmount(clipBottomAmount); 365 if (previousAmount != clipBottomAmount) { 366 applyRoundness(); 367 } 368 } 369 setOutlineAlpha(float alpha)370 protected void setOutlineAlpha(float alpha) { 371 if (alpha != mOutlineAlpha) { 372 mOutlineAlpha = alpha; 373 applyRoundness(); 374 } 375 } 376 377 @Override getOutlineAlpha()378 public float getOutlineAlpha() { 379 return mOutlineAlpha; 380 } 381 setOutlineRect(RectF rect)382 protected void setOutlineRect(RectF rect) { 383 if (rect != null) { 384 setOutlineRect(rect.left, rect.top, rect.right, rect.bottom); 385 } else { 386 mCustomOutline = false; 387 applyRoundness(); 388 } 389 } 390 391 @Override getOutlineTranslation()392 public int getOutlineTranslation() { 393 return mCustomOutline ? mOutlineRect.left : (int) getTranslation(); 394 } 395 updateOutline()396 public void updateOutline() { 397 if (mCustomOutline) { 398 return; 399 } 400 boolean hasOutline = needsOutline(); 401 setOutlineProvider(hasOutline ? mProvider : null); 402 } 403 404 /** 405 * @return Whether the view currently needs an outline. This is usually {@code false} in case 406 * it doesn't have a background. 407 */ needsOutline()408 protected boolean needsOutline() { 409 if (isChildInGroup()) { 410 return isGroupExpanded() && !isGroupExpansionChanging(); 411 } else if (isSummaryWithChildren()) { 412 return !isGroupExpanded() || isGroupExpansionChanging(); 413 } 414 return true; 415 } 416 isOutlineShowing()417 public boolean isOutlineShowing() { 418 ViewOutlineProvider op = getOutlineProvider(); 419 return op != null; 420 } 421 setOutlineRect(float left, float top, float right, float bottom)422 protected void setOutlineRect(float left, float top, float right, float bottom) { 423 mCustomOutline = true; 424 425 mOutlineRect.set((int) left, (int) top, (int) right, (int) bottom); 426 427 // Outlines need to be at least 1 dp 428 mOutlineRect.bottom = (int) Math.max(top, mOutlineRect.bottom); 429 mOutlineRect.right = (int) Math.max(left, mOutlineRect.right); 430 applyRoundness(); 431 } 432 getCustomClipPath(View child)433 public Path getCustomClipPath(View child) { 434 return null; 435 } 436 } 437