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