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