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