1 /* 2 * Copyright (C) 2024 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 18 19 import android.content.res.ColorStateList 20 import android.graphics.ColorFilter 21 import android.graphics.PorterDuff 22 import android.graphics.PorterDuffColorFilter 23 import android.os.Build 24 import android.util.Log 25 import androidx.core.animation.Animator 26 import com.android.car.docklib.data.DockAppItem 27 import com.android.car.docklib.view.animation.ExcitementAnimationHelper 28 import com.google.android.material.imageview.ShapeableImageView 29 import java.util.EnumSet 30 import kotlin.math.floor 31 32 /** 33 * Controller to help manage states for individual DockItemViews. 34 */ 35 class DockItemViewController( 36 private val staticIconStrokeWidth: Float, 37 private val dynamicIconStrokeWidth: Float, 38 private val excitedIconStrokeWidth: Float, 39 private val staticIconStrokeColor: Int, 40 private val excitedIconStrokeColor: Int, 41 private val restrictedIconStrokeColor: Int, 42 private val defaultIconColor: Int, 43 private val excitedColorFilter: ColorFilter, 44 private val restrictedColorFilter: ColorFilter, 45 private val excitedIconColorFilterAlpha: Float, 46 private val exciteAnimationDuration: Int, 47 ) { 48 49 companion object { 50 private val TAG = DockItemViewController::class.simpleName 51 private val DEBUG = Build.isDebuggable() 52 private const val DEFAULT_STROKE_WIDTH = 0f 53 private const val INITIAL_COLOR_FILTER_ALPHA = 0f 54 } 55 56 private enum class TypeStates { 57 DYNAMIC, STATIC 58 } 59 60 private enum class OptionalStates { 61 EXCITED, UPDATING, RESTRICTED, ACTIVE_MEDIA 62 } 63 64 private var dynamicIconStrokeColor: Int = defaultIconColor 65 private var updatingColor: Int = defaultIconColor 66 private var exciteAnimator: Animator? = null 67 68 private var typeState: Enum<TypeStates> = TypeStates.STATIC 69 private val optionalState: EnumSet<OptionalStates> = EnumSet.noneOf(OptionalStates::class.java) 70 71 /** 72 * Setter to set if the DockItem is dynamic. 73 */ setDynamicnull74 fun setDynamic(dynamicIconStrokeColor: Int) { 75 typeState = TypeStates.DYNAMIC 76 this.dynamicIconStrokeColor = dynamicIconStrokeColor 77 } 78 79 /** 80 * Setter to set if the DockItem is static. 81 */ setStaticnull82 fun setStatic() { 83 typeState = TypeStates.STATIC 84 this.dynamicIconStrokeColor = defaultIconColor 85 } 86 87 /** 88 * Setter to set if the DockItem is excited. Returns true if the state was changed. 89 */ setExcitednull90 fun setExcited(isExcited: Boolean): Boolean { 91 if ((isExcited && optionalState.contains(OptionalStates.EXCITED)) || 92 (!isExcited && !optionalState.contains(OptionalStates.EXCITED)) 93 ) { 94 return false 95 } 96 97 if (isExcited) { 98 optionalState.add(OptionalStates.EXCITED) 99 } else { 100 optionalState.remove(OptionalStates.EXCITED) 101 } 102 return true 103 } 104 105 /** 106 * Setter to set if the DockItem is updating to another [DockAppItem] 107 * @param updatingColor color to use when app is updating. Generally the icon color of the next 108 * [DockAppItem] 109 * @return Returns true if the state was changed, false otherwise 110 */ setUpdatingnull111 fun setUpdating(isUpdating: Boolean, updatingColor: Int?): Boolean { 112 if ((isUpdating && optionalState.contains(OptionalStates.UPDATING)) || 113 (!isUpdating && !optionalState.contains(OptionalStates.UPDATING)) 114 ) { 115 return false 116 } 117 118 if (isUpdating) { 119 optionalState.add(OptionalStates.UPDATING) 120 } else { 121 optionalState.remove(OptionalStates.UPDATING) 122 } 123 this.updatingColor = updatingColor ?: defaultIconColor 124 return true 125 } 126 127 /** 128 * Setter to set if the DockItem is restricted. Returns true if the state was changed. 129 */ setRestrictednull130 fun setRestricted(isRestricted: Boolean): Boolean { 131 if ((isRestricted && optionalState.contains(OptionalStates.RESTRICTED)) || 132 (!isRestricted && !optionalState.contains(OptionalStates.RESTRICTED)) 133 ) { 134 return false 135 } 136 137 if (isRestricted) { 138 optionalState.add(OptionalStates.RESTRICTED) 139 } else { 140 optionalState.remove(OptionalStates.RESTRICTED) 141 } 142 return true 143 } 144 145 /** Tracks whether the app item has an active media session or not */ setHasActiveMediaSessionnull146 fun setHasActiveMediaSession( 147 hasMediaSession: Boolean 148 ): Boolean { 149 if ((hasMediaSession && optionalState.contains(OptionalStates.ACTIVE_MEDIA)) || 150 (!hasMediaSession && !optionalState.contains(OptionalStates.ACTIVE_MEDIA)) 151 ) { 152 return false 153 } 154 155 if (hasMediaSession) { 156 optionalState.add(OptionalStates.ACTIVE_MEDIA) 157 } else { 158 optionalState.remove(OptionalStates.ACTIVE_MEDIA) 159 } 160 return true 161 } 162 163 /** @return whether the view should be restricted or not */ shouldBeRestrictednull164 fun shouldBeRestricted(): Boolean { 165 return optionalState.contains(OptionalStates.RESTRICTED) && 166 !optionalState.contains(OptionalStates.ACTIVE_MEDIA) 167 } 168 169 /** 170 * Updates the [appIcon] based on the current state 171 */ updateViewBasedOnStatenull172 fun updateViewBasedOnState(appIcon: ShapeableImageView) { 173 if (DEBUG) { 174 Log.d( 175 TAG, 176 "updateViewBasedOnState, typeState: $typeState, optionalState: $optionalState" 177 ) 178 } 179 if (exciteAnimator != null) { 180 exciteAnimator?.cancel() 181 exciteAnimator = null 182 } 183 appIcon.strokeColor = ColorStateList.valueOf(getStrokeColor()) 184 appIcon.strokeWidth = getStrokeWidth() 185 appIcon.colorFilter = getColorFilter() 186 val cp = getContentPadding() 187 // ContentPadding should not be set before the measure phase of the view otherwise it might 188 // set incorrect padding values on the view. 189 appIcon.post { appIcon.setContentPadding(cp, cp, cp, cp) } 190 } 191 192 /** 193 * Animate the [appIcon] to be excited or reset after being excited. 194 */ animateAppIconExcitednull195 fun animateAppIconExcited(appIcon: ShapeableImageView) { 196 val isAnimationOngoing = exciteAnimator?.isRunning ?: false 197 if (DEBUG) { 198 Log.d( 199 TAG, 200 "Excite animation{ " + 201 "isExciting: ${optionalState.contains(OptionalStates.EXCITED)}, " + 202 "isAnimationOngoing: $isAnimationOngoing }" 203 ) 204 } 205 exciteAnimator?.cancel() 206 207 val toStrokeWidth: Float = getStrokeWidth() 208 val toContentPadding: Int = getContentPadding() 209 val toStrokeColor: Int = getStrokeColor() 210 val toColorFilterAlpha: Float = if (optionalState.contains(OptionalStates.EXCITED)) { 211 excitedIconColorFilterAlpha 212 } else { 213 INITIAL_COLOR_FILTER_ALPHA 214 } 215 216 val successCallback = { 217 exciteAnimator = null 218 updateViewBasedOnState(appIcon) 219 } 220 221 val failureCallback = { 222 exciteAnimator = null 223 updateViewBasedOnState(appIcon) 224 } 225 226 exciteAnimator = ExcitementAnimationHelper.getExcitementAnimator( 227 appIcon, 228 exciteAnimationDuration.toLong(), 229 toStrokeWidth, 230 toStrokeColor, 231 toContentPadding, 232 toColorFilterAlpha, 233 successCallback, 234 failureCallback 235 ) 236 exciteAnimator?.start() 237 } 238 getStrokeColornull239 private fun getStrokeColor(): Int { 240 if (optionalState.contains(OptionalStates.UPDATING)) { 241 return updatingColor 242 } else if (shouldBeRestricted()) { 243 return restrictedIconStrokeColor 244 } else if (optionalState.contains(OptionalStates.EXCITED)) { 245 return excitedIconStrokeColor 246 } else if (typeState == TypeStates.STATIC) { 247 return staticIconStrokeColor 248 } else if (typeState == TypeStates.DYNAMIC) { 249 return dynamicIconStrokeColor 250 } 251 return defaultIconColor 252 } 253 getStrokeWidthnull254 private fun getStrokeWidth(): Float { 255 if (optionalState.contains(OptionalStates.EXCITED)) { 256 return excitedIconStrokeWidth 257 } else if (typeState == TypeStates.STATIC) { 258 return staticIconStrokeWidth 259 } else if (typeState == TypeStates.DYNAMIC) { 260 return dynamicIconStrokeWidth 261 } 262 return DEFAULT_STROKE_WIDTH 263 } 264 getContentPaddingnull265 private fun getContentPadding(): Int { 266 return getContentPaddingFromStrokeWidth(getStrokeWidth()) 267 } 268 getColorFilternull269 private fun getColorFilter(): ColorFilter? { 270 if (optionalState.contains(OptionalStates.UPDATING)) { 271 return PorterDuffColorFilter(updatingColor, PorterDuff.Mode.SRC_OVER) 272 } else if (shouldBeRestricted()){ 273 return restrictedColorFilter 274 } else if (optionalState.contains(OptionalStates.EXCITED)) { 275 return excitedColorFilter 276 } 277 return null 278 } 279 getContentPaddingFromStrokeWidthnull280 private fun getContentPaddingFromStrokeWidth(strokeWidth: Float): Int = 281 floor(strokeWidth / 2).toInt() 282 } 283