1 /* 2 * Copyright (C) 2021 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.qs.tileimpl 18 19 import android.animation.ArgbEvaluator 20 import android.animation.PropertyValuesHolder 21 import android.animation.ValueAnimator 22 import android.content.Context 23 import android.content.res.ColorStateList 24 import android.content.res.Configuration 25 import android.content.res.Resources.ID_NULL 26 import android.graphics.drawable.Drawable 27 import android.graphics.drawable.RippleDrawable 28 import android.os.Trace 29 import android.service.quicksettings.Tile 30 import android.text.TextUtils 31 import android.util.Log 32 import android.util.TypedValue 33 import android.view.Gravity 34 import android.view.LayoutInflater 35 import android.view.View 36 import android.view.ViewGroup 37 import android.view.accessibility.AccessibilityEvent 38 import android.view.accessibility.AccessibilityNodeInfo 39 import android.widget.Button 40 import android.widget.ImageView 41 import android.widget.LinearLayout 42 import android.widget.Switch 43 import android.widget.TextView 44 import androidx.annotation.VisibleForTesting 45 import com.android.settingslib.Utils 46 import com.android.systemui.FontSizeUtils 47 import com.android.systemui.R 48 import com.android.systemui.animation.LaunchableView 49 import com.android.systemui.animation.LaunchableViewDelegate 50 import com.android.systemui.plugins.qs.QSIconView 51 import com.android.systemui.plugins.qs.QSTile 52 import com.android.systemui.plugins.qs.QSTile.BooleanState 53 import com.android.systemui.plugins.qs.QSTileView 54 import com.android.systemui.qs.logging.QSLogger 55 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH 56 import java.util.Objects 57 58 private const val TAG = "QSTileViewImpl" 59 open class QSTileViewImpl @JvmOverloads constructor( 60 context: Context, 61 private val _icon: QSIconView, 62 private val collapsed: Boolean = false 63 ) : QSTileView(context), HeightOverrideable, LaunchableView { 64 65 companion object { 66 private const val INVALID = -1 67 private const val BACKGROUND_NAME = "background" 68 private const val LABEL_NAME = "label" 69 private const val SECONDARY_LABEL_NAME = "secondaryLabel" 70 private const val CHEVRON_NAME = "chevron" 71 const val UNAVAILABLE_ALPHA = 0.3f 72 @VisibleForTesting 73 internal const val TILE_STATE_RES_PREFIX = "tile_states_" 74 } 75 76 private var _position: Int = INVALID 77 78 override fun setPosition(position: Int) { 79 _position = position 80 } 81 82 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE 83 set(value) { 84 if (field == value) return 85 field = value 86 updateHeight() 87 } 88 89 override var squishinessFraction: Float = 1f 90 set(value) { 91 if (field == value) return 92 field = value 93 updateHeight() 94 } 95 96 private val colorActive = Utils.getColorAttrDefaultColor(context, R.attr.shadeActive) 97 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.shadeInactive) 98 private val colorUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.shadeDisabled) 99 100 private val colorLabelActive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive) 101 private val colorLabelInactive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive) 102 private val colorLabelUnavailable = 103 Utils.getColorAttrDefaultColor(context, R.attr.outline) 104 105 private val colorSecondaryLabelActive = 106 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActiveVariant) 107 private val colorSecondaryLabelInactive = 108 Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactiveVariant) 109 private val colorSecondaryLabelUnavailable = 110 Utils.getColorAttrDefaultColor(context, R.attr.outline) 111 112 private lateinit var label: TextView 113 protected lateinit var secondaryLabel: TextView 114 private lateinit var labelContainer: IgnorableChildLinearLayout 115 protected lateinit var sideView: ViewGroup 116 private lateinit var customDrawableView: ImageView 117 private lateinit var chevronView: ImageView 118 private var mQsLogger: QSLogger? = null 119 120 /** 121 * Controls if tile background is set to a [RippleDrawable] see [setClickable] 122 */ 123 protected var showRippleEffect = true 124 125 private lateinit var ripple: RippleDrawable 126 private lateinit var colorBackgroundDrawable: Drawable 127 private var paintColor: Int = 0 128 private val singleAnimator: ValueAnimator = ValueAnimator().apply { 129 setDuration(QS_ANIM_LENGTH) 130 addUpdateListener { animation -> 131 setAllColors( 132 // These casts will throw an exception if some property is missing. We should 133 // always have all properties. 134 animation.getAnimatedValue(BACKGROUND_NAME) as Int, 135 animation.getAnimatedValue(LABEL_NAME) as Int, 136 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int, 137 animation.getAnimatedValue(CHEVRON_NAME) as Int 138 ) 139 } 140 } 141 142 private var accessibilityClass: String? = null 143 private var stateDescriptionDeltas: CharSequence? = null 144 private var lastStateDescription: CharSequence? = null 145 private var tileState = false 146 private var lastState = INVALID 147 private val launchableViewDelegate = LaunchableViewDelegate( 148 this, 149 superSetVisibility = { super.setVisibility(it) }, 150 ) 151 private var lastDisabledByPolicy = false 152 153 private val locInScreen = IntArray(2) 154 155 init { 156 val typedValue = TypedValue() 157 if (!getContext().theme.resolveAttribute(R.attr.isQsTheme, typedValue, true)) { 158 throw IllegalStateException("QSViewImpl must be inflated with a theme that contains " + 159 "Theme.SystemUI.QuickSettings") 160 } 161 setId(generateViewId()) 162 orientation = LinearLayout.HORIZONTAL 163 gravity = Gravity.CENTER_VERTICAL or Gravity.START 164 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES 165 clipChildren = false 166 clipToPadding = false 167 isFocusable = true 168 background = createTileBackground() 169 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE)) 170 171 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding) 172 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding) 173 setPaddingRelative(startPadding, padding, padding, padding) 174 175 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size) 176 addView(_icon, LayoutParams(iconSize, iconSize)) 177 178 createAndAddLabels() 179 createAndAddSideView() 180 } 181 182 override fun onConfigurationChanged(newConfig: Configuration?) { 183 super.onConfigurationChanged(newConfig) 184 updateResources() 185 } 186 187 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 188 Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure") 189 super.onMeasure(widthMeasureSpec, heightMeasureSpec) 190 Trace.endSection() 191 } 192 193 override fun resetOverride() { 194 heightOverride = HeightOverrideable.NO_OVERRIDE 195 updateHeight() 196 } 197 198 fun setQsLogger(qsLogger: QSLogger) { 199 mQsLogger = qsLogger 200 } 201 202 fun updateResources() { 203 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size) 204 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size) 205 206 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size) 207 _icon.layoutParams.apply { 208 height = iconSize 209 width = iconSize 210 } 211 212 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding) 213 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding) 214 setPaddingRelative(startPadding, padding, padding, padding) 215 216 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin) 217 (labelContainer.layoutParams as MarginLayoutParams).apply { 218 marginStart = labelMargin 219 } 220 221 (sideView.layoutParams as MarginLayoutParams).apply { 222 marginStart = labelMargin 223 } 224 (chevronView.layoutParams as MarginLayoutParams).apply { 225 height = iconSize 226 width = iconSize 227 } 228 229 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin) 230 (customDrawableView.layoutParams as MarginLayoutParams).apply { 231 height = iconSize 232 marginEnd = endMargin 233 } 234 } 235 236 private fun createAndAddLabels() { 237 labelContainer = LayoutInflater.from(context) 238 .inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout 239 label = labelContainer.requireViewById(R.id.tile_label) 240 secondaryLabel = labelContainer.requireViewById(R.id.app_label) 241 if (collapsed) { 242 labelContainer.ignoreLastView = true 243 // Ideally, it'd be great if the parent could set this up when measuring just this child 244 // instead of the View class having to support this. However, due to the mysteries of 245 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its 246 // sibling methods to have special behavior for labelContainer. 247 labelContainer.forceUnspecifiedMeasure = true 248 secondaryLabel.alpha = 0f 249 } 250 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE)) 251 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE)) 252 addView(labelContainer) 253 } 254 255 private fun createAndAddSideView() { 256 sideView = LayoutInflater.from(context) 257 .inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup 258 customDrawableView = sideView.requireViewById(R.id.customDrawable) 259 chevronView = sideView.requireViewById(R.id.chevron) 260 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE)) 261 addView(sideView) 262 } 263 264 fun createTileBackground(): Drawable { 265 ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable 266 colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background) 267 return ripple 268 } 269 270 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { 271 super.onLayout(changed, l, t, r, b) 272 updateHeight() 273 } 274 275 private fun updateHeight() { 276 val actualHeight = if (heightOverride != HeightOverrideable.NO_OVERRIDE) { 277 heightOverride 278 } else { 279 measuredHeight 280 } 281 // Limit how much we affect the height, so we don't have rounding artifacts when the tile 282 // is too short. 283 val constrainedSquishiness = constrainSquishiness(squishinessFraction) 284 bottom = top + (actualHeight * constrainedSquishiness).toInt() 285 scrollY = (actualHeight - height) / 2 286 } 287 288 override fun updateAccessibilityOrder(previousView: View?): View { 289 accessibilityTraversalAfter = previousView?.id ?: ID_NULL 290 return this 291 } 292 293 override fun getIcon(): QSIconView { 294 return _icon 295 } 296 297 override fun getIconWithBackground(): View { 298 return icon 299 } 300 301 override fun init(tile: QSTile) { 302 init( 303 { v: View? -> tile.click(this) }, 304 { view: View? -> 305 tile.longClick(this) 306 true 307 } 308 ) 309 } 310 311 private fun init( 312 click: OnClickListener?, 313 longClick: OnLongClickListener? 314 ) { 315 setOnClickListener(click) 316 onLongClickListener = longClick 317 } 318 319 override fun onStateChanged(state: QSTile.State) { 320 // We cannot use the handler here because sometimes, the views are not attached (if they 321 // are in a page that the ViewPager hasn't attached). Instead, we use a runnable where 322 // all its instances are `equal` to each other, so they can be used to remove them from the 323 // queue. 324 // This means that at any given time there's at most one enqueued runnable to change state. 325 // However, as we only ever care about the last state posted, this is fine. 326 val runnable = StateChangeRunnable(state.copy()) 327 removeCallbacks(runnable) 328 post(runnable) 329 } 330 331 override fun getDetailY(): Int { 332 return top + height / 2 333 } 334 335 override fun hasOverlappingRendering(): Boolean { 336 // Avoid layers for this layout - we don't need them. 337 return false 338 } 339 340 override fun setClickable(clickable: Boolean) { 341 super.setClickable(clickable) 342 background = if (clickable && showRippleEffect) { 343 ripple.also { 344 // In case that the colorBackgroundDrawable was used as the background, make sure 345 // it has the correct callback instead of null 346 colorBackgroundDrawable.callback = it 347 } 348 } else { 349 colorBackgroundDrawable 350 } 351 } 352 353 override fun getLabelContainer(): View { 354 return labelContainer 355 } 356 357 override fun getLabel(): View { 358 return label 359 } 360 361 override fun getSecondaryLabel(): View { 362 return secondaryLabel 363 } 364 365 override fun getSecondaryIcon(): View { 366 return sideView 367 } 368 369 override fun setShouldBlockVisibilityChanges(block: Boolean) { 370 launchableViewDelegate.setShouldBlockVisibilityChanges(block) 371 } 372 373 override fun setVisibility(visibility: Int) { 374 launchableViewDelegate.setVisibility(visibility) 375 } 376 377 // Accessibility 378 379 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { 380 super.onInitializeAccessibilityEvent(event) 381 if (!TextUtils.isEmpty(accessibilityClass)) { 382 event.className = accessibilityClass 383 } 384 if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION && 385 stateDescriptionDeltas != null) { 386 event.text.add(stateDescriptionDeltas) 387 stateDescriptionDeltas = null 388 } 389 } 390 391 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { 392 super.onInitializeAccessibilityNodeInfo(info) 393 // Clear selected state so it is not announce by talkback. 394 info.isSelected = false 395 info.text = if (TextUtils.isEmpty(secondaryLabel.text)) { 396 "${label.text}" 397 } else { 398 "${label.text}, ${secondaryLabel.text}" 399 } 400 if (lastDisabledByPolicy) { 401 info.addAction( 402 AccessibilityNodeInfo.AccessibilityAction( 403 AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id, 404 resources.getString( 405 R.string.accessibility_tile_disabled_by_policy_action_description 406 ) 407 ) 408 ) 409 } 410 if (!TextUtils.isEmpty(accessibilityClass)) { 411 info.className = if (lastDisabledByPolicy) { 412 Button::class.java.name 413 } else { 414 accessibilityClass 415 } 416 if (Switch::class.java.name == accessibilityClass) { 417 info.isChecked = tileState 418 info.isCheckable = true 419 if (isLongClickable) { 420 info.addAction( 421 AccessibilityNodeInfo.AccessibilityAction( 422 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id, 423 resources.getString( 424 R.string.accessibility_long_click_tile))) 425 } 426 } 427 } 428 if (_position != INVALID) { 429 info.collectionItemInfo = 430 AccessibilityNodeInfo.CollectionItemInfo(_position, 1, 0, 1, false) 431 } 432 } 433 434 override fun toString(): String { 435 val sb = StringBuilder(javaClass.simpleName).append('[') 436 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})") 437 sb.append(", iconView=$_icon") 438 sb.append(", tileState=$tileState") 439 sb.append("]") 440 return sb.toString() 441 } 442 443 // HANDLE STATE CHANGES RELATED METHODS 444 445 protected open fun handleStateChanged(state: QSTile.State) { 446 val allowAnimations = animationsEnabled() 447 isClickable = state.state != Tile.STATE_UNAVAILABLE 448 isLongClickable = state.handlesLongClick 449 icon.setIcon(state, allowAnimations) 450 contentDescription = state.contentDescription 451 452 // State handling and description 453 val stateDescription = StringBuilder() 454 val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec) 455 val stateText = state.getStateText(arrayResId, resources) 456 state.secondaryLabel = state.getSecondaryLabel(stateText) 457 if (!TextUtils.isEmpty(stateText)) { 458 stateDescription.append(stateText) 459 } 460 if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) { 461 stateDescription.append(", ") 462 stateDescription.append(getUnavailableText(state.spec)) 463 } 464 if (!TextUtils.isEmpty(state.stateDescription)) { 465 stateDescription.append(", ") 466 stateDescription.append(state.stateDescription) 467 if (lastState != INVALID && state.state == lastState && 468 state.stateDescription != lastStateDescription) { 469 stateDescriptionDeltas = state.stateDescription 470 } 471 } 472 473 setStateDescription(stateDescription.toString()) 474 lastStateDescription = state.stateDescription 475 476 accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) { 477 null 478 } else { 479 state.expandedAccessibilityClassName 480 } 481 482 if (state is BooleanState) { 483 val newState = state.value 484 if (tileState != newState) { 485 tileState = newState 486 } 487 } 488 // 489 490 // Labels 491 if (!Objects.equals(label.text, state.label)) { 492 label.text = state.label 493 } 494 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) { 495 secondaryLabel.text = state.secondaryLabel 496 secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) { 497 GONE 498 } else { 499 VISIBLE 500 } 501 } 502 503 // Colors 504 if (state.state != lastState || state.disabledByPolicy != lastDisabledByPolicy) { 505 singleAnimator.cancel() 506 mQsLogger?.logTileBackgroundColorUpdateIfInternetTile( 507 state.spec, 508 state.state, 509 state.disabledByPolicy, 510 getBackgroundColorForState(state.state, state.disabledByPolicy)) 511 if (allowAnimations) { 512 singleAnimator.setValues( 513 colorValuesHolder( 514 BACKGROUND_NAME, 515 paintColor, 516 getBackgroundColorForState(state.state, state.disabledByPolicy) 517 ), 518 colorValuesHolder( 519 LABEL_NAME, 520 label.currentTextColor, 521 getLabelColorForState(state.state, state.disabledByPolicy) 522 ), 523 colorValuesHolder( 524 SECONDARY_LABEL_NAME, 525 secondaryLabel.currentTextColor, 526 getSecondaryLabelColorForState(state.state, state.disabledByPolicy) 527 ), 528 colorValuesHolder( 529 CHEVRON_NAME, 530 chevronView.imageTintList?.defaultColor ?: 0, 531 getChevronColorForState(state.state, state.disabledByPolicy) 532 ) 533 ) 534 singleAnimator.start() 535 } else { 536 setAllColors( 537 getBackgroundColorForState(state.state, state.disabledByPolicy), 538 getLabelColorForState(state.state, state.disabledByPolicy), 539 getSecondaryLabelColorForState(state.state, state.disabledByPolicy), 540 getChevronColorForState(state.state, state.disabledByPolicy) 541 ) 542 } 543 } 544 545 // Right side icon 546 loadSideViewDrawableIfNecessary(state) 547 548 label.isEnabled = !state.disabledByPolicy 549 550 lastState = state.state 551 lastDisabledByPolicy = state.disabledByPolicy 552 } 553 554 private fun setAllColors( 555 backgroundColor: Int, 556 labelColor: Int, 557 secondaryLabelColor: Int, 558 chevronColor: Int 559 ) { 560 setColor(backgroundColor) 561 setLabelColor(labelColor) 562 setSecondaryLabelColor(secondaryLabelColor) 563 setChevronColor(chevronColor) 564 } 565 566 private fun setColor(color: Int) { 567 colorBackgroundDrawable.mutate().setTint(color) 568 paintColor = color 569 } 570 571 private fun setLabelColor(color: Int) { 572 label.setTextColor(color) 573 } 574 575 private fun setSecondaryLabelColor(color: Int) { 576 secondaryLabel.setTextColor(color) 577 } 578 579 private fun setChevronColor(color: Int) { 580 chevronView.imageTintList = ColorStateList.valueOf(color) 581 } 582 583 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) { 584 if (state.sideViewCustomDrawable != null) { 585 customDrawableView.setImageDrawable(state.sideViewCustomDrawable) 586 customDrawableView.visibility = VISIBLE 587 chevronView.visibility = GONE 588 } else if (state !is BooleanState || state.forceExpandIcon) { 589 customDrawableView.setImageDrawable(null) 590 customDrawableView.visibility = GONE 591 chevronView.visibility = VISIBLE 592 } else { 593 customDrawableView.setImageDrawable(null) 594 customDrawableView.visibility = GONE 595 chevronView.visibility = GONE 596 } 597 } 598 599 private fun getUnavailableText(spec: String?): String { 600 val arrayResId = SubtitleArrayMapping.getSubtitleId(spec) 601 return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE] 602 } 603 604 /* 605 * The view should not be animated if it's not on screen and no part of it is visible. 606 */ 607 protected open fun animationsEnabled(): Boolean { 608 if (!isShown) { 609 return false 610 } 611 if (alpha != 1f) { 612 return false 613 } 614 getLocationOnScreen(locInScreen) 615 return locInScreen.get(1) >= -height 616 } 617 618 private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int { 619 return when { 620 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable 621 state == Tile.STATE_ACTIVE -> colorActive 622 state == Tile.STATE_INACTIVE -> colorInactive 623 else -> { 624 Log.e(TAG, "Invalid state $state") 625 0 626 } 627 } 628 } 629 630 private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int { 631 return when { 632 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable 633 state == Tile.STATE_ACTIVE -> colorLabelActive 634 state == Tile.STATE_INACTIVE -> colorLabelInactive 635 else -> { 636 Log.e(TAG, "Invalid state $state") 637 0 638 } 639 } 640 } 641 642 private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int { 643 return when { 644 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable 645 state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive 646 state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive 647 else -> { 648 Log.e(TAG, "Invalid state $state") 649 0 650 } 651 } 652 } 653 654 private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int = 655 getSecondaryLabelColorForState(state, disabledByPolicy) 656 657 @VisibleForTesting 658 internal fun getCurrentColors(): List<Int> = listOf( 659 paintColor, 660 label.currentTextColor, 661 secondaryLabel.currentTextColor, 662 chevronView.imageTintList?.defaultColor ?: 0 663 ) 664 665 inner class StateChangeRunnable(private val state: QSTile.State) : Runnable { 666 override fun run() { 667 handleStateChanged(state) 668 } 669 670 // We want all instances of this runnable to be equal to each other, so they can be used to 671 // remove previous instances from the Handler/RunQueue of this view 672 override fun equals(other: Any?): Boolean { 673 return other is StateChangeRunnable 674 } 675 676 // This makes sure that all instances have the same hashcode (because they are `equal`) 677 override fun hashCode(): Int { 678 return StateChangeRunnable::class.hashCode() 679 } 680 } 681 } 682 683 fun constrainSquishiness(squish: Float): Float { 684 return 0.1f + squish * 0.9f 685 } 686 687 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder { 688 return PropertyValuesHolder.ofInt(name, *values).apply { 689 setEvaluator(ArgbEvaluator.getInstance()) 690 } 691 } 692