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(view = textView, viewModel = viewModel.text) 85 viewModel.contentDescription?.let { 86 ContentDescriptionViewBinder.bind(view = textView, viewModel = it) 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.contentDescription ?: 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 var animationDisposableHandle: DisposableHandle? = null 140 141 viewModel.key 142 .flatMapLatest { 143 // If the key changed, then it means that this binding is no longer 144 // rendering the UI for the same option as before, we nullify the 145 // last selected value to "forget" that we've ever seen a value for 146 // isSelected, effectively starting a new so the first update 147 // doesn't animate. 148 lastSelected = null 149 viewModel.isSelected 150 } 151 .collect { isSelected -> 152 animationDisposableHandle?.dispose() 153 if (foregroundTintSpec != null && foregroundView is ImageView) { 154 if (isSelected) { 155 foregroundView.setColorFilter( 156 foregroundTintSpec.selectedColor 157 ) 158 } else { 159 foregroundView.setColorFilter( 160 foregroundTintSpec.unselectedColor 161 ) 162 } 163 } 164 165 animationDisposableHandle = 166 animatedSelection( 167 animationSpec = animationSpec, 168 borderView = borderView, 169 contentView = backgroundView, 170 isSelected = isSelected, 171 animate = lastSelected != null && lastSelected != isSelected, 172 ) 173 view.isSelected = isSelected 174 lastSelected = isSelected 175 } 176 } 177 178 launch { 179 viewModel.onClicked.collect { onClicked -> 180 view.setOnClickListener( 181 if (onClicked != null) { 182 View.OnClickListener { onClicked.invoke() } 183 } else { 184 null 185 } 186 ) 187 } 188 } 189 } 190 } 191 192 return DisposableHandle { job.cancel() } 193 } 194 195 /** 196 * Uses a "bouncy" animation to animate the selecting or un-selecting of a view with a 197 * background and a border. 198 * 199 * Note that it is expected that the [borderView] is below the [contentView] on the z axis so 200 * the latter obscures the former at rest. 201 * 202 * @param borderView A view for the selection border that should be shown when the view is 203 * 204 * ``` 205 * selected. 206 * @param contentView 207 * ``` 208 * 209 * The view containing the opaque part of the view. 210 * 211 * @param isSelected Whether the view is selected or not. 212 * @param animationSpec The specification for the animation. 213 * @param animate Whether to animate; if `false`, will jump directly to the final state without 214 * 215 * ``` 216 * animating. 217 * ``` 218 */ 219 private fun animatedSelection( 220 borderView: View, 221 contentView: View, 222 isSelected: Boolean, 223 animationSpec: AnimationSpec, 224 animate: Boolean = true, 225 ): DisposableHandle? { 226 if (isSelected) { 227 if (!animate) { 228 borderView.alpha = 1f 229 borderView.scale(1f) 230 contentView.scale(0.86f) 231 return null 232 } 233 234 // Border scale. 235 val borderAnimator = 236 borderView 237 .animate() 238 .scale(1.099f) 239 .setDuration(animationSpec.durationMs / 2) 240 .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f)) 241 .withStartAction { 242 borderView.scaleX = 0.98f 243 borderView.scaleY = 0.98f 244 borderView.alpha = 1f 245 } 246 .withEndAction { 247 borderView 248 .animate() 249 .scale(1f) 250 .setDuration(animationSpec.durationMs / 2) 251 .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f)) 252 .start() 253 } 254 .also { it.start() } 255 256 // Background scale. 257 val backgroundAnimator = 258 contentView 259 .animate() 260 .scale(0.9321f) 261 .setDuration(animationSpec.durationMs / 2) 262 .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f)) 263 .withEndAction { 264 contentView 265 .animate() 266 .scale(0.86f) 267 .setDuration(animationSpec.durationMs / 2) 268 .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f)) 269 .start() 270 } 271 .also { it.start() } 272 273 return DisposableHandle { 274 borderAnimator.cancel() 275 backgroundAnimator.cancel() 276 } 277 } else { 278 if (!animate) { 279 borderView.alpha = 0f 280 contentView.scale(1f) 281 return null 282 } 283 284 // Border opacity. 285 val borderOpacityAnimator = 286 borderView 287 .animate() 288 .alpha(0f) 289 .setDuration(animationSpec.durationMs / 2) 290 .setInterpolator(LinearInterpolator()) 291 .also { it.start() } 292 293 // Border scale. 294 val borderScaleAnimator = 295 borderView 296 .animate() 297 .scale(1f) 298 .setDuration(animationSpec.durationMs) 299 .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f)) 300 .also { it.start() } 301 302 // Background scale. 303 val backgroundAnimator = 304 contentView 305 .animate() 306 .scale(1f) 307 .setDuration(animationSpec.durationMs) 308 .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f)) 309 .also { it.start() } 310 311 return DisposableHandle { 312 borderOpacityAnimator.cancel() 313 borderScaleAnimator.cancel() 314 backgroundAnimator.cancel() 315 } 316 } 317 } 318 319 data class AnimationSpec( 320 /** Opacity of the option when it's enabled. */ 321 val enabledAlpha: Float = 1f, 322 /** Opacity of the option background when it's disabled. */ 323 val disabledBackgroundAlpha: Float = 0.5f, 324 /** Opacity of the option foreground when it's disabled. */ 325 val disabledForegroundAlpha: Float = 0.5f, 326 /** Opacity of the option text when it's disabled. */ 327 val disabledTextAlpha: Float = 0.61f, 328 /** Duration of the animation, in milliseconds. */ 329 val durationMs: Long = 333L, 330 ) 331 332 data class TintSpec(@ColorInt val selectedColor: Int, @ColorInt val unselectedColor: Int) 333 334 private fun View.scale(scale: Float) { 335 scaleX = scale 336 scaleY = scale 337 } 338 339 private fun ViewPropertyAnimator.scale(scale: Float): ViewPropertyAnimator { 340 return scaleX(scale).scaleY(scale) 341 } 342 } 343