1 /* <lambda>null2 * 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 18 @file:OptIn(ExperimentalCoroutinesApi::class) 19 20 package com.android.wallpaper.picker.option.ui.binder 21 22 import android.animation.ValueAnimator 23 import android.view.View 24 import android.widget.ImageView 25 import android.widget.TextView 26 import androidx.core.view.isVisible 27 import androidx.dynamicanimation.animation.SpringAnimation 28 import androidx.dynamicanimation.animation.SpringForce 29 import androidx.lifecycle.Lifecycle 30 import androidx.lifecycle.LifecycleOwner 31 import androidx.lifecycle.lifecycleScope 32 import androidx.lifecycle.repeatOnLifecycle 33 import com.android.wallpaper.R 34 import com.android.wallpaper.picker.common.icon.ui.viewbinder.ContentDescriptionViewBinder 35 import com.android.wallpaper.picker.common.text.ui.viewbinder.TextViewBinder 36 import com.android.wallpaper.picker.customization.ui.binder.ColorUpdateBinder 37 import com.android.wallpaper.picker.customization.ui.viewmodel.ColorUpdateViewModel 38 import com.android.wallpaper.picker.option.ui.view.OptionItemBackground 39 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel 40 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel2 41 import java.lang.ref.WeakReference 42 import kotlinx.coroutines.DisposableHandle 43 import kotlinx.coroutines.ExperimentalCoroutinesApi 44 import kotlinx.coroutines.flow.combine 45 import kotlinx.coroutines.flow.flatMapLatest 46 import kotlinx.coroutines.launch 47 48 object OptionItemBinder2 { 49 /** 50 * Binds the given [View] to the given [OptionItemViewModel]. 51 * 52 * The child views of [view] must be named and arranged in the following manner, from top of the 53 * z-axis to the bottom: 54 * - [R.id.foreground] is the foreground drawable ([ImageView]). 55 * - [R.id.background] is the view in the background ([OptionItemBackground]). 56 * 57 * In order to show the animation when an option item is selected, you may need to disable the 58 * clipping of child views across the view-tree using: 59 * ``` 60 * android:clipChildren="false" 61 * ``` 62 * 63 * Optionally, there may be an [R.id.text] [TextView] to show the text from the view-model. If 64 * one is not supplied, the text will be used as the content description of the icon. 65 * 66 * @param view The view; it must contain the child views described above. 67 * @param viewModel The view-model. 68 * @param lifecycleOwner The [LifecycleOwner]. 69 * @param animationfSpec The specification for the animation. 70 * @return A [DisposableHandle] that must be invoked when the view is recycled. 71 */ 72 fun bind( 73 view: View, 74 viewModel: OptionItemViewModel2<*>, 75 lifecycleOwner: LifecycleOwner, 76 animationSpec: AnimationSpec = AnimationSpec(), 77 colorUpdateViewModel: WeakReference<ColorUpdateViewModel>, 78 shouldAnimateColor: () -> Boolean, 79 ): DisposableHandle { 80 val backgroundView: OptionItemBackground = view.requireViewById(R.id.background) 81 val foregroundView: ImageView? = view.findViewById(R.id.foreground) 82 val textView: TextView? = view.findViewById(R.id.text) 83 84 if (textView != null && viewModel.isTextUserVisible) { 85 TextViewBinder.bind(view = textView, viewModel = viewModel.text) 86 } else { 87 // Use the text as the content description of the foreground if we don't have a TextView 88 // dedicated to for the text. 89 ContentDescriptionViewBinder.bind( 90 view = foregroundView ?: backgroundView, 91 viewModel = viewModel.text, 92 ) 93 } 94 textView?.isVisible = viewModel.isTextUserVisible 95 96 textView?.alpha = 97 if (viewModel.isEnabled) { 98 animationSpec.enabledAlpha 99 } else { 100 animationSpec.disabledTextAlpha 101 } 102 103 backgroundView.alpha = 104 if (viewModel.isEnabled) { 105 animationSpec.enabledAlpha 106 } else { 107 animationSpec.disabledBackgroundAlpha 108 } 109 110 foregroundView?.alpha = 111 if (viewModel.isEnabled) { 112 animationSpec.enabledAlpha 113 } else { 114 animationSpec.disabledForegroundAlpha 115 } 116 117 view.onLongClickListener = 118 if (viewModel.onLongClicked != null) { 119 View.OnLongClickListener { 120 viewModel.onLongClicked.invoke() 121 true 122 } 123 } else { 124 null 125 } 126 view.isLongClickable = viewModel.onLongClicked != null 127 128 colorUpdateViewModel.get()?.let { 129 ColorUpdateBinder.bind( 130 setColor = { color -> textView?.setTextColor(color) }, 131 color = it.colorOnSurfaceVariant, 132 shouldAnimate = shouldAnimateColor, 133 lifecycleOwner = lifecycleOwner, 134 ) 135 ColorUpdateBinder.bind( 136 setColor = { color -> foregroundView?.setColorFilter(color) }, 137 color = 138 combine( 139 viewModel.isSelected, 140 it.colorOnSurfaceVariant, 141 it.colorOnPrimaryFixed, 142 ) { isSelected, onSurfaceVariant, onPrimaryFixed -> 143 if (isSelected) { 144 onPrimaryFixed 145 } else { 146 onSurfaceVariant 147 } 148 }, 149 shouldAnimate = { false }, 150 lifecycleOwner = lifecycleOwner, 151 ) 152 ColorUpdateBinder.bind( 153 setColor = { color -> backgroundView.setUnselectedColor(color) }, 154 color = it.colorSurfaceContainerHigh, 155 shouldAnimate = shouldAnimateColor, 156 lifecycleOwner = lifecycleOwner, 157 ) 158 159 ColorUpdateBinder.bind( 160 setColor = { color -> backgroundView.setSelectedColor(color) }, 161 color = it.colorPrimaryFixedDim, 162 shouldAnimate = shouldAnimateColor, 163 lifecycleOwner = lifecycleOwner, 164 ) 165 } 166 167 val job = 168 lifecycleOwner.lifecycleScope.launch { 169 lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 170 launch { 171 // We only want to animate if the view-model is updating in response to a 172 // selection or deselection of the same exact option. For that, we save the 173 // last value of isSelected. 174 var lastSelected: Boolean? = null 175 176 viewModel.key 177 .flatMapLatest { 178 // If the key changed, then it means that this binding is no longer 179 // rendering the UI for the same option as before, we nullify the 180 // last selected value to "forget" that we've ever seen a value for 181 // isSelected, effectively starting a new so the first update 182 // doesn't animate. 183 lastSelected = null 184 viewModel.isSelected 185 } 186 .collect { isSelected -> 187 val shouldAnimate = 188 lastSelected != null && lastSelected != isSelected 189 if (shouldAnimate) { 190 animatedSelection( 191 backgroundView = backgroundView, 192 isSelected = isSelected, 193 animationSpec = animationSpec, 194 ) 195 } else { 196 backgroundView.setProgress(if (isSelected) 1f else 0f) 197 } 198 199 view.isSelected = isSelected 200 lastSelected = isSelected 201 } 202 } 203 204 launch { 205 viewModel.onClicked.collect { onClicked -> 206 view.setOnClickListener( 207 if (onClicked != null) { 208 View.OnClickListener { onClicked.invoke() } 209 } else { 210 null 211 } 212 ) 213 } 214 } 215 } 216 } 217 218 return DisposableHandle { job.cancel() } 219 } 220 221 private fun animatedSelection( 222 backgroundView: OptionItemBackground, 223 isSelected: Boolean, 224 animationSpec: AnimationSpec, 225 ) { 226 if (isSelected) { 227 val springForce = 228 SpringForce().apply { 229 stiffness = SpringForce.STIFFNESS_MEDIUM 230 dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY 231 finalPosition = 1f 232 } 233 234 SpringAnimation(backgroundView, SpringAnimation.SCALE_X, 1f) 235 .apply { 236 setStartVelocity(5f) 237 spring = springForce 238 } 239 .start() 240 241 SpringAnimation(backgroundView, SpringAnimation.SCALE_Y, 1f) 242 .apply { 243 setStartVelocity(5f) 244 spring = springForce 245 } 246 .start() 247 248 ValueAnimator.ofFloat(0f, 1f) 249 .apply { 250 duration = animationSpec.durationMs 251 addUpdateListener { 252 val progress = it.animatedValue as Float 253 backgroundView.setProgress(progress) 254 } 255 } 256 .start() 257 } else { 258 ValueAnimator.ofFloat(1f, 0f) 259 .apply { 260 duration = animationSpec.durationMs 261 addUpdateListener { 262 val progress = it.animatedValue as Float 263 backgroundView.setProgress(progress) 264 } 265 } 266 .start() 267 } 268 } 269 270 data class AnimationSpec( 271 /** Opacity of the option when it's enabled. */ 272 val enabledAlpha: Float = 1f, 273 /** Opacity of the option background when it's disabled. */ 274 val disabledBackgroundAlpha: Float = 0.5f, 275 /** Opacity of the option foreground when it's disabled. */ 276 val disabledForegroundAlpha: Float = 0.5f, 277 /** Opacity of the option text when it's disabled. */ 278 val disabledTextAlpha: Float = 0.61f, 279 /** Duration of the animation, in milliseconds. */ 280 val durationMs: Long = 333L, 281 ) 282 } 283