• 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(view = textView, viewModel = viewModel.text)
85             viewModel.contentDescription?.let {
86                 ContentDescriptionViewBinder.bind(view = textView, viewModel = it)
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.contentDescription ?: 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                         var animationDisposableHandle: DisposableHandle? = null
140 
141                         viewModel.key
142                             .flatMapLatest {
143                                 // If the key changed, then it means that this binding is no longer
144                                 // rendering the UI for the same option as before, we nullify the
145                                 // last  selected value to "forget" that we've ever seen a value for
146                                 // isSelected, effectively starting a new so the first update
147                                 // doesn't animate.
148                                 lastSelected = null
149                                 viewModel.isSelected
150                             }
151                             .collect { isSelected ->
152                                 animationDisposableHandle?.dispose()
153                                 if (foregroundTintSpec != null && foregroundView is ImageView) {
154                                     if (isSelected) {
155                                         foregroundView.setColorFilter(
156                                             foregroundTintSpec.selectedColor
157                                         )
158                                     } else {
159                                         foregroundView.setColorFilter(
160                                             foregroundTintSpec.unselectedColor
161                                         )
162                                     }
163                                 }
164 
165                                 animationDisposableHandle =
166                                     animatedSelection(
167                                         animationSpec = animationSpec,
168                                         borderView = borderView,
169                                         contentView = backgroundView,
170                                         isSelected = isSelected,
171                                         animate = lastSelected != null && lastSelected != isSelected,
172                                     )
173                                 view.isSelected = isSelected
174                                 lastSelected = isSelected
175                             }
176                     }
177 
178                     launch {
179                         viewModel.onClicked.collect { onClicked ->
180                             view.setOnClickListener(
181                                 if (onClicked != null) {
182                                     View.OnClickListener { onClicked.invoke() }
183                                 } else {
184                                     null
185                                 }
186                             )
187                         }
188                     }
189                 }
190             }
191 
192         return DisposableHandle { job.cancel() }
193     }
194 
195     /**
196      * Uses a "bouncy" animation to animate the selecting or un-selecting of a view with a
197      * background and a border.
198      *
199      * Note that it is expected that the [borderView] is below the [contentView] on the z axis so
200      * the latter obscures the former at rest.
201      *
202      * @param borderView A view for the selection border that should be shown when the view is
203      *
204      * ```
205      *     selected.
206      * @param contentView
207      * ```
208      *
209      * The view containing the opaque part of the view.
210      *
211      * @param isSelected Whether the view is selected or not.
212      * @param animationSpec The specification for the animation.
213      * @param animate Whether to animate; if `false`, will jump directly to the final state without
214      *
215      * ```
216      *     animating.
217      * ```
218      */
219     private fun animatedSelection(
220         borderView: View,
221         contentView: View,
222         isSelected: Boolean,
223         animationSpec: AnimationSpec,
224         animate: Boolean = true,
225     ): DisposableHandle? {
226         if (isSelected) {
227             if (!animate) {
228                 borderView.alpha = 1f
229                 borderView.scale(1f)
230                 contentView.scale(0.86f)
231                 return null
232             }
233 
234             // Border scale.
235             val borderAnimator =
236                 borderView
237                     .animate()
238                     .scale(1.099f)
239                     .setDuration(animationSpec.durationMs / 2)
240                     .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f))
241                     .withStartAction {
242                         borderView.scaleX = 0.98f
243                         borderView.scaleY = 0.98f
244                         borderView.alpha = 1f
245                     }
246                     .withEndAction {
247                         borderView
248                             .animate()
249                             .scale(1f)
250                             .setDuration(animationSpec.durationMs / 2)
251                             .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f))
252                             .start()
253                     }
254                     .also { it.start() }
255 
256             // Background scale.
257             val backgroundAnimator =
258                 contentView
259                     .animate()
260                     .scale(0.9321f)
261                     .setDuration(animationSpec.durationMs / 2)
262                     .setInterpolator(PathInterpolator(0.29f, 0f, 0.67f, 1f))
263                     .withEndAction {
264                         contentView
265                             .animate()
266                             .scale(0.86f)
267                             .setDuration(animationSpec.durationMs / 2)
268                             .setInterpolator(PathInterpolator(0.33f, 0f, 0.15f, 1f))
269                             .start()
270                     }
271                     .also { it.start() }
272 
273             return DisposableHandle {
274                 borderAnimator.cancel()
275                 backgroundAnimator.cancel()
276             }
277         } else {
278             if (!animate) {
279                 borderView.alpha = 0f
280                 contentView.scale(1f)
281                 return null
282             }
283 
284             // Border opacity.
285             val borderOpacityAnimator =
286                 borderView
287                     .animate()
288                     .alpha(0f)
289                     .setDuration(animationSpec.durationMs / 2)
290                     .setInterpolator(LinearInterpolator())
291                     .also { it.start() }
292 
293             // Border scale.
294             val borderScaleAnimator =
295                 borderView
296                     .animate()
297                     .scale(1f)
298                     .setDuration(animationSpec.durationMs)
299                     .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f))
300                     .also { it.start() }
301 
302             // Background scale.
303             val backgroundAnimator =
304                 contentView
305                     .animate()
306                     .scale(1f)
307                     .setDuration(animationSpec.durationMs)
308                     .setInterpolator(PathInterpolator(0.2f, 0f, 0f, 1f))
309                     .also { it.start() }
310 
311             return DisposableHandle {
312                 borderOpacityAnimator.cancel()
313                 borderScaleAnimator.cancel()
314                 backgroundAnimator.cancel()
315             }
316         }
317     }
318 
319     data class AnimationSpec(
320         /** Opacity of the option when it's enabled. */
321         val enabledAlpha: Float = 1f,
322         /** Opacity of the option background when it's disabled. */
323         val disabledBackgroundAlpha: Float = 0.5f,
324         /** Opacity of the option foreground when it's disabled. */
325         val disabledForegroundAlpha: Float = 0.5f,
326         /** Opacity of the option text when it's disabled. */
327         val disabledTextAlpha: Float = 0.61f,
328         /** Duration of the animation, in milliseconds. */
329         val durationMs: Long = 333L,
330     )
331 
332     data class TintSpec(@ColorInt val selectedColor: Int, @ColorInt val unselectedColor: Int)
333 
334     private fun View.scale(scale: Float) {
335         scaleX = scale
336         scaleY = scale
337     }
338 
339     private fun ViewPropertyAnimator.scale(scale: Float): ViewPropertyAnimator {
340         return scaleX(scale).scaleY(scale)
341     }
342 }
343