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