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