• 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.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