• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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