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