1 /* 2 * Copyright (C) 2023 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.car.docklib.view.animation 18 19 import android.content.res.ColorStateList 20 import android.graphics.Color 21 import android.graphics.PorterDuff 22 import android.graphics.PorterDuffColorFilter 23 import android.os.Build 24 import android.util.Log 25 import android.util.Property 26 import android.view.View 27 import android.widget.ImageView 28 import androidx.annotation.ColorInt 29 import androidx.annotation.VisibleForTesting 30 import androidx.core.animation.Animator 31 import androidx.core.animation.ArgbEvaluator 32 import androidx.core.animation.ObjectAnimator 33 import androidx.core.animation.PathInterpolator 34 import androidx.core.animation.PropertyValuesHolder 35 import androidx.core.graphics.alpha 36 import androidx.core.graphics.toColorLong 37 import com.google.android.material.imageview.ShapeableImageView 38 39 /** 40 * Helper class to build an [Animator] that can animate a view to be excited or reset. This is 41 * generally used to excite the dock item when something is hovered over it. 42 */ 43 class ExcitementAnimationHelper { 44 companion object { 45 private const val TAG = "ExcitementAnimator" 46 private val DEBUG = Build.isDebuggable() 47 private const val PVH_STROKE_WIDTH = "strokeWidth" 48 private const val PVH_STROKE_COLOR = "strokeColor" 49 private const val PVH_PADDING = "padding" 50 private const val PVH_COLOR_FILTER = "colorFilter" 51 52 /** 53 * Creates an [Animator] using the final values to be animated to. The initial values to 54 * start the animation from are taken from the View. 55 * 56 * Some assumptions: 57 * 1. strokeWidth, strokeColor and contentPadding will only be animated if the given [view] 58 * is of type [ShapeableImageView] 59 * 2. colorFilter will only be animated if the given [view] is of type [ImageView] 60 * 3. The colorFilter set on the [view] should be [PorterDuffColorFilter] to be able to get 61 * the initial value from. 62 * 63 * @param view [View] that should be animated and sourced the initial animation values 64 * from. 65 * @param animationDuration length of the animation 66 * @param toStrokeWidth final strokeWidth value 67 * @param toStrokeColor final strokeColor value 68 * @param toContentPadding final content padding value, applied to all sides 69 * @param toColorFilterAlpha final alpha value of the colorFilter 70 * @param successCallback called when the animation has completed successfully 71 * @param failureCallback called when the animation is unsuccessful/cancelled 72 */ getExcitementAnimatornull73 fun getExcitementAnimator( 74 view: View, 75 animationDuration: Long, 76 toStrokeWidth: Float, 77 @ColorInt toStrokeColor: Int, 78 toContentPadding: Int, 79 toColorFilterAlpha: Float, 80 successCallback: Runnable, 81 failureCallback: Runnable 82 ): Animator { 83 // todo(b/312718542): hidden api(PorterDuffColorFilter.getAlpha) usage 84 return getExcitementAnimator( 85 view, 86 animationDuration, 87 fromStrokeWidth = getStrokeWidth(view, defaultWidth = 0f), 88 toStrokeWidth = toStrokeWidth, 89 fromStrokeColor = getStrokeColor(view, defaultColor = Color.rgb(0f, 0f, 0f)), 90 toStrokeColor = toStrokeColor, 91 fromPadding = getAverageContentPadding(view, defaultContentPadding = 0), 92 toContentPadding = toContentPadding, 93 fromColorFilterAlpha = getColorFilterAlpha(view, defaultColorFilterAlpha = 0f), 94 toColorFilterAlpha = toColorFilterAlpha, 95 successCallback, 96 failureCallback 97 ) 98 } 99 getExcitementAnimatornull100 private fun getExcitementAnimator( 101 view: View, 102 animationDuration: Long, 103 fromStrokeWidth: Float, 104 toStrokeWidth: Float, 105 @ColorInt fromStrokeColor: Int, 106 @ColorInt toStrokeColor: Int, 107 fromPadding: Int, 108 toContentPadding: Int, 109 fromColorFilterAlpha: Float, 110 toColorFilterAlpha: Float, 111 successCallback: Runnable, 112 failureCallback: Runnable 113 ): Animator { 114 if (DEBUG) { 115 Log.d( 116 TAG, 117 "getExcitementAnimator{" + 118 "view: $view, " + 119 "animationDuration: $animationDuration, " + 120 "fromStrokeWidth: $fromStrokeWidth," + 121 "toStrokeWidth: $toStrokeWidth," + 122 "fromStrokeColor: $fromStrokeColor," + 123 "toStrokeColor: $toStrokeColor," + 124 "fromPadding: $fromPadding," + 125 "toContentPadding: $toContentPadding," + 126 "fromColorFilterAlpha: $fromColorFilterAlpha," + 127 "toColorFilterAlpha: $toColorFilterAlpha," + 128 "}" 129 ) 130 } 131 var pvhStrokeWidth: PropertyValuesHolder? = null 132 val pvhPadding: PropertyValuesHolder? 133 var pvhColorFilter: PropertyValuesHolder? = null 134 var pvhStrokeColor: PropertyValuesHolder? = null 135 136 if (view is ShapeableImageView) { 137 pvhStrokeWidth = PropertyValuesHolder.ofFloat( 138 PVH_STROKE_WIDTH, 139 fromStrokeWidth, 140 toStrokeWidth 141 ) 142 143 pvhStrokeColor = 144 PropertyValuesHolder.ofObject( 145 object : Property<View, Int>(Int::class.java, PVH_STROKE_COLOR) { 146 override fun get(view: View?): Int { 147 return getStrokeColor(view, defaultColor = fromStrokeColor) 148 } 149 150 override fun set(view: View?, value: Int?) { 151 if (view is ShapeableImageView && value != null) { 152 view.strokeColor = ColorStateList.valueOf(value) 153 } 154 } 155 }, 156 ArgbEvaluator.getInstance(), 157 fromStrokeColor, 158 toStrokeColor 159 ) 160 } 161 162 pvhPadding = PropertyValuesHolder.ofInt( 163 object : Property<View, Int>(Int::class.java, PVH_PADDING) { 164 override fun get(view: View?): Int { 165 return if (view != null) { 166 getAverageContentPadding(view, defaultContentPadding = 0) 167 } else { 168 0 169 } 170 } 171 172 override fun set(view: View?, value: Int?) { 173 if (view != null && value != null) setContentPadding(view, value) 174 } 175 }, 176 fromPadding, 177 toContentPadding 178 ) 179 180 if (view is ImageView) { 181 pvhColorFilter = 182 PropertyValuesHolder.ofFloat( 183 object : Property<View, Float>( 184 Float::class.java, 185 PVH_COLOR_FILTER 186 ) { 187 override fun get(view: View?): Float { 188 return getColorFilterAlpha(view, defaultColorFilterAlpha = 0f) 189 } 190 191 override fun set(view: View?, value: Float?) { 192 if (view is ImageView && value != null) { 193 view.colorFilter = PorterDuffColorFilter( 194 Color.argb(value, 0f, 0f, 0f), 195 PorterDuff.Mode.DARKEN 196 ) 197 } 198 } 199 }, 200 fromColorFilterAlpha, 201 toColorFilterAlpha 202 ) 203 } 204 205 val animator = ObjectAnimator.ofPropertyValuesHolder( 206 view, 207 pvhStrokeWidth, 208 pvhPadding, 209 pvhColorFilter, 210 pvhStrokeColor 211 ) 212 animator.setDuration(animationDuration) 213 animator.interpolator = PathInterpolator(0f, 0f, 0f, 1f) 214 animator.addListener(getAnimatorListener(successCallback, failureCallback)) 215 return animator 216 } 217 218 @VisibleForTesting getAnimatorListenernull219 fun getAnimatorListener( 220 successCallback: Runnable, 221 failureCallback: Runnable 222 ): Animator.AnimatorListener { 223 return object : Animator.AnimatorListener { 224 private var isCancelled = false 225 override fun onAnimationStart(animator: Animator) { 226 isCancelled = false 227 } 228 229 override fun onAnimationEnd(animator: Animator) { 230 if (!isCancelled) successCallback.run() 231 } 232 233 override fun onAnimationCancel(animator: Animator) { 234 isCancelled = true 235 failureCallback.run() 236 } 237 238 override fun onAnimationRepeat(animator: Animator) { 239 // no-op 240 } 241 } 242 } 243 setContentPaddingnull244 private fun setContentPadding(view: View, contentPadding: Int) { 245 (view as? ShapeableImageView)?.setContentPadding( 246 contentPadding, 247 contentPadding, 248 contentPadding, 249 contentPadding 250 ) 251 } 252 getAverageContentPaddingnull253 private fun getAverageContentPadding(view: View, defaultContentPadding: Int): Int { 254 (view as? ShapeableImageView)?.let { 255 return (it.contentPaddingStart + it.contentPaddingEnd + 256 it.contentPaddingTop + it.contentPaddingBottom) / 4 257 } 258 return defaultContentPadding 259 } 260 getColorFilterAlphanull261 private fun getColorFilterAlpha(view: View?, defaultColorFilterAlpha: Float): Float { 262 return ((view as? ImageView) 263 ?.colorFilter as? PorterDuffColorFilter) 264 ?.color?.toColorLong()?.alpha ?: defaultColorFilterAlpha 265 } 266 getStrokeWidthnull267 private fun getStrokeWidth(view: View?, defaultWidth: Float): Float { 268 return (view as? ShapeableImageView)?.strokeWidth ?: defaultWidth 269 } 270 getStrokeColornull271 private fun getStrokeColor(view: View?, @ColorInt defaultColor: Int): Int { 272 return (view as? ShapeableImageView) 273 ?.strokeColor?.getColorForState(null, defaultColor) ?: defaultColor 274 } 275 } 276 } 277