1 /* <lambda>null2 * 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 18 @file:OptIn(ExperimentalCoroutinesApi::class) 19 20 package com.android.wallpaper.picker.option.ui.binder 21 22 import android.view.View 23 import android.view.ViewPropertyAnimator 24 import android.view.animation.LinearInterpolator 25 import android.view.animation.PathInterpolator 26 import android.widget.ImageView 27 import android.widget.TextView 28 import androidx.annotation.ColorInt 29 import androidx.core.view.isVisible 30 import androidx.lifecycle.Lifecycle 31 import androidx.lifecycle.LifecycleOwner 32 import androidx.lifecycle.lifecycleScope 33 import androidx.lifecycle.repeatOnLifecycle 34 import com.android.wallpaper.R 35 import com.android.wallpaper.picker.common.icon.ui.viewbinder.ContentDescriptionViewBinder 36 import com.android.wallpaper.picker.common.text.ui.viewbinder.TextViewBinder 37 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel 38 import kotlinx.coroutines.DisposableHandle 39 import kotlinx.coroutines.ExperimentalCoroutinesApi 40 import kotlinx.coroutines.flow.flatMapLatest 41 import kotlinx.coroutines.launch 42 43 object OptionItemBinder { 44 /** 45 * Binds the given [View] to the given [OptionItemViewModel]. 46 * 47 * The child views of [view] must be named and arranged in the following manner, from top of the 48 * z-axis to the bottom: 49 * - [R.id.foreground] is the foreground drawable ([ImageView]). 50 * - [R.id.background] is the view in the background ([View]). 51 * - [R.id.selection_border] is a view rendering a border. It must have the same exact size as 52 * [R.id.background] ([View]) and must be placed below it on the z-axis (you read that right). 53 * 54 * The animation logic in this binder takes care of scaling up the border at the right time to 55 * help it peek out around the background. In order to allow for this, you may need to disable 56 * the clipping of child views across the view-tree using: 57 * ``` 58 * android:clipChildren="false" 59 * ``` 60 * 61 * Optionally, there may be an [R.id.text] [TextView] to show the text from the view-model. If 62 * one is not supplied, the text will be used as the content description of the icon. 63 * 64 * @param view The view; it must contain the child views described above. 65 * @param viewModel The view-model. 66 * @param lifecycleOwner The [LifecycleOwner]. 67 * @param animationSpec The specification for the animation. 68 * @param foregroundTintSpec The specification of how to tint the foreground icons. 69 * @return A [DisposableHandle] that must be invoked when the view is recycled. 70 */ 71 fun bind( 72 view: View, 73 viewModel: OptionItemViewModel<*>, 74 lifecycleOwner: LifecycleOwner, 75 animationSpec: AnimationSpec = AnimationSpec(), 76 foregroundTintSpec: TintSpec? = null, 77 ): DisposableHandle { 78 val borderView: View = view.requireViewById(R.id.selection_border) 79 val backgroundView: View = view.requireViewById(R.id.background) 80 val foregroundView: View = view.requireViewById(R.id.foreground) 81 val textView: TextView? = view.findViewById(R.id.text) 82 83 if (textView != null && viewModel.isTextUserVisible) { 84 TextViewBinder.bind( 85 view = textView, 86 viewModel = viewModel.text, 87 ) 88 } else { 89 // Use the text as the content description of the foreground if we don't have a TextView 90 // dedicated to for the text. 91 ContentDescriptionViewBinder.bind( 92 view = foregroundView, 93 viewModel = viewModel.text, 94 ) 95 } 96 textView?.isVisible = viewModel.isTextUserVisible 97 98 textView?.alpha = 99 if (viewModel.isEnabled) { 100 animationSpec.enabledAlpha 101 } else { 102 animationSpec.disabledTextAlpha 103 } 104 105 backgroundView.alpha = 106 if (viewModel.isEnabled) { 107 animationSpec.enabledAlpha 108 } else { 109 animationSpec.disabledBackgroundAlpha 110 } 111 112 foregroundView.alpha = 113 if (viewModel.isEnabled) { 114 animationSpec.enabledAlpha 115 } else { 116 animationSpec.disabledForegroundAlpha 117 } 118 119 view.onLongClickListener = 120 if (viewModel.onLongClicked != null) { 121 View.OnLongClickListener { 122 viewModel.onLongClicked.invoke() 123 true 124 } 125 } else { 126 null 127 } 128 view.isLongClickable = viewModel.onLongClicked != null 129 130 val job = 131 lifecycleOwner.lifecycleScope.launch { 132 lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 133 launch { 134 // We only want to animate if the view-model is updating in response to a 135 // selection or deselection of the same exact option. For that, we save the 136 // last 137 // value of isSelected. 138 var lastSelected: Boolean? = null 139 140 viewModel.key 141 .flatMapLatest { 142 // If the key changed, then it means that this binding is no longer 143 // rendering the UI for the same option as before, we nullify the 144 // last 145 // selected value to "forget" that we've ever seen a value for 146 // isSelected, 147 // effectively starting anew so the first update doesn't animate. 148 lastSelected = null 149 viewModel.isSelected 150 } 151 .collect { isSelected -> 152 if (foregroundTintSpec != null && foregroundView is ImageView) { 153 if (isSelected) { 154 foregroundView.setColorFilter( 155 foregroundTintSpec.selectedColor 156 ) 157 } else { 158 foregroundView.setColorFilter( 159 foregroundTintSpec.unselectedColor 160 ) 161 } 162 } 163 164 animatedSelection( 165 animationSpec = animationSpec, 166 borderView = borderView, 167 contentView = backgroundView, 168 isSelected = isSelected, 169 animate = lastSelected != null && lastSelected != isSelected, 170 ) 171 view.isSelected = isSelected 172 lastSelected = isSelected 173 } 174 } 175 176 launch { 177 viewModel.onClicked.collect { onClicked -> 178 view.setOnClickListener( 179 if (onClicked != null) { 180 View.OnClickListener { onClicked.invoke() } 181 } else { 182 null 183 } 184 ) 185 } 186 } 187 } 188 } 189 190 return DisposableHandle { job.cancel() } 191 } 192 193 /** 194 * Uses a "bouncy" animation to animate the selecting or un-selecting of a view with a 195 * background and a border. 196 * 197 * Note that it is expected that the [borderView] is below the [contentView] on the z axis so 198 * the latter obscures the former at rest. 199 * 200 * @param borderView A view for the selection border that should be shown when the view is 201 * 202 * ``` 203 * selected. 204 * @param contentView 205 * ``` 206 * 207 * The view containing the opaque part of the view. 208 * 209 * @param isSelected Whether the view is selected or not. 210 * @param animationSpec The specification for the animation. 211 * @param animate Whether to animate; if `false`, will jump directly to the final state without 212 * 213 * ``` 214 * animating. 215 * ``` 216 */ 217 private fun animatedSelection( 218 borderView: View, 219 contentView: View, 220 isSelected: Boolean, 221 animationSpec: AnimationSpec, 222 animate: Boolean = true, 223 ) { 224 if (isSelected) { 225 if (!animate) { 226 borderView.alpha = 1f 227 borderView.scale(1f) 228 contentView.scale(0.86f) 229 return 230 } 231 232 // Border scale. 233 borderView 234 .animate() 235 .scale(1.099f) 236 .setDuration(animationSpec.durationMs / 2) 237 .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f)) 238 .withStartAction { 239 borderView.scaleX = 0.98f 240 borderView.scaleY = 0.98f 241 borderView.alpha = 1f 242 } 243 .withEndAction { 244 borderView 245 .animate() 246 .scale(1f) 247 .setDuration(animationSpec.durationMs / 2) 248 .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f)) 249 .start() 250 } 251 .start() 252 253 // Background scale. 254 contentView 255 .animate() 256 .scale(0.9321f) 257 .setDuration(animationSpec.durationMs / 2) 258 .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f)) 259 .withEndAction { 260 contentView 261 .animate() 262 .scale(0.86f) 263 .setDuration(animationSpec.durationMs / 2) 264 .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f)) 265 .start() 266 } 267 .start() 268 } else { 269 if (!animate) { 270 borderView.alpha = 0f 271 contentView.scale(1f) 272 return 273 } 274 275 // Border opacity. 276 borderView 277 .animate() 278 .alpha(0f) 279 .setDuration(animationSpec.durationMs / 2) 280 .setInterpolator(LinearInterpolator()) 281 .start() 282 283 // Border scale. 284 borderView 285 .animate() 286 .scale(1f) 287 .setDuration(animationSpec.durationMs) 288 .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f)) 289 .start() 290 291 // Background scale. 292 contentView 293 .animate() 294 .scale(1f) 295 .setDuration(animationSpec.durationMs) 296 .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f)) 297 .start() 298 } 299 } 300 301 data class AnimationSpec( 302 /** Opacity of the option when it's enabled. */ 303 val enabledAlpha: Float = 1f, 304 /** Opacity of the option background when it's disabled. */ 305 val disabledBackgroundAlpha: Float = 0.5f, 306 /** Opacity of the option foreground when it's disabled. */ 307 val disabledForegroundAlpha: Float = 0.5f, 308 /** Opacity of the option text when it's disabled. */ 309 val disabledTextAlpha: Float = 0.61f, 310 /** Duration of the animation, in milliseconds. */ 311 val durationMs: Long = 333L, 312 ) 313 314 data class TintSpec( 315 @ColorInt val selectedColor: Int, 316 @ColorInt val unselectedColor: Int, 317 ) 318 319 private fun View.scale(scale: Float) { 320 scaleX = scale 321 scaleY = scale 322 } 323 324 private fun ViewPropertyAnimator.scale(scale: Float): ViewPropertyAnimator { 325 return scaleX(scale).scaleY(scale) 326 } 327 } 328