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