• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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.animation.ValueAnimator
23 import android.view.View
24 import android.widget.ImageView
25 import android.widget.TextView
26 import androidx.core.view.isVisible
27 import androidx.dynamicanimation.animation.SpringAnimation
28 import androidx.dynamicanimation.animation.SpringForce
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.customization.ui.binder.ColorUpdateBinder
37 import com.android.wallpaper.picker.customization.ui.viewmodel.ColorUpdateViewModel
38 import com.android.wallpaper.picker.option.ui.view.OptionItemBackground
39 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
40 import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel2
41 import java.lang.ref.WeakReference
42 import kotlinx.coroutines.DisposableHandle
43 import kotlinx.coroutines.ExperimentalCoroutinesApi
44 import kotlinx.coroutines.flow.combine
45 import kotlinx.coroutines.flow.flatMapLatest
46 import kotlinx.coroutines.launch
47 
48 object OptionItemBinder2 {
49     /**
50      * Binds the given [View] to the given [OptionItemViewModel].
51      *
52      * The child views of [view] must be named and arranged in the following manner, from top of the
53      * z-axis to the bottom:
54      * - [R.id.foreground] is the foreground drawable ([ImageView]).
55      * - [R.id.background] is the view in the background ([OptionItemBackground]).
56      *
57      * In order to show the animation when an option item is selected, you may need to disable the
58      * clipping of child views across the view-tree using:
59      * ```
60      * android:clipChildren="false"
61      * ```
62      *
63      * Optionally, there may be an [R.id.text] [TextView] to show the text from the view-model. If
64      * one is not supplied, the text will be used as the content description of the icon.
65      *
66      * @param view The view; it must contain the child views described above.
67      * @param viewModel The view-model.
68      * @param lifecycleOwner The [LifecycleOwner].
69      * @param animationfSpec The specification for the animation.
70      * @return A [DisposableHandle] that must be invoked when the view is recycled.
71      */
72     fun bind(
73         view: View,
74         viewModel: OptionItemViewModel2<*>,
75         lifecycleOwner: LifecycleOwner,
76         animationSpec: AnimationSpec = AnimationSpec(),
77         colorUpdateViewModel: WeakReference<ColorUpdateViewModel>,
78         shouldAnimateColor: () -> Boolean,
79     ): DisposableHandle {
80         val backgroundView: OptionItemBackground = view.requireViewById(R.id.background)
81         val foregroundView: ImageView? = view.findViewById(R.id.foreground)
82         val textView: TextView? = view.findViewById(R.id.text)
83 
84         if (textView != null && viewModel.isTextUserVisible) {
85             TextViewBinder.bind(view = textView, viewModel = viewModel.text)
86         } else {
87             // Use the text as the content description of the foreground if we don't have a TextView
88             // dedicated to for the text.
89             ContentDescriptionViewBinder.bind(
90                 view = foregroundView ?: backgroundView,
91                 viewModel = viewModel.text,
92             )
93         }
94         textView?.isVisible = viewModel.isTextUserVisible
95 
96         textView?.alpha =
97             if (viewModel.isEnabled) {
98                 animationSpec.enabledAlpha
99             } else {
100                 animationSpec.disabledTextAlpha
101             }
102 
103         backgroundView.alpha =
104             if (viewModel.isEnabled) {
105                 animationSpec.enabledAlpha
106             } else {
107                 animationSpec.disabledBackgroundAlpha
108             }
109 
110         foregroundView?.alpha =
111             if (viewModel.isEnabled) {
112                 animationSpec.enabledAlpha
113             } else {
114                 animationSpec.disabledForegroundAlpha
115             }
116 
117         view.onLongClickListener =
118             if (viewModel.onLongClicked != null) {
119                 View.OnLongClickListener {
120                     viewModel.onLongClicked.invoke()
121                     true
122                 }
123             } else {
124                 null
125             }
126         view.isLongClickable = viewModel.onLongClicked != null
127 
128         colorUpdateViewModel.get()?.let {
129             ColorUpdateBinder.bind(
130                 setColor = { color -> textView?.setTextColor(color) },
131                 color = it.colorOnSurfaceVariant,
132                 shouldAnimate = shouldAnimateColor,
133                 lifecycleOwner = lifecycleOwner,
134             )
135             ColorUpdateBinder.bind(
136                 setColor = { color -> foregroundView?.setColorFilter(color) },
137                 color =
138                     combine(
139                         viewModel.isSelected,
140                         it.colorOnSurfaceVariant,
141                         it.colorOnPrimaryFixed,
142                     ) { isSelected, onSurfaceVariant, onPrimaryFixed ->
143                         if (isSelected) {
144                             onPrimaryFixed
145                         } else {
146                             onSurfaceVariant
147                         }
148                     },
149                 shouldAnimate = { false },
150                 lifecycleOwner = lifecycleOwner,
151             )
152             ColorUpdateBinder.bind(
153                 setColor = { color -> backgroundView.setUnselectedColor(color) },
154                 color = it.colorSurfaceContainerHigh,
155                 shouldAnimate = shouldAnimateColor,
156                 lifecycleOwner = lifecycleOwner,
157             )
158 
159             ColorUpdateBinder.bind(
160                 setColor = { color -> backgroundView.setSelectedColor(color) },
161                 color = it.colorPrimaryFixedDim,
162                 shouldAnimate = shouldAnimateColor,
163                 lifecycleOwner = lifecycleOwner,
164             )
165         }
166 
167         val job =
168             lifecycleOwner.lifecycleScope.launch {
169                 lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
170                     launch {
171                         // We only want to animate if the view-model is updating in response to a
172                         // selection or deselection of the same exact option. For that, we save the
173                         // last value of isSelected.
174                         var lastSelected: Boolean? = null
175 
176                         viewModel.key
177                             .flatMapLatest {
178                                 // If the key changed, then it means that this binding is no longer
179                                 // rendering the UI for the same option as before, we nullify the
180                                 // last  selected value to "forget" that we've ever seen a value for
181                                 // isSelected, effectively starting a new so the first update
182                                 // doesn't animate.
183                                 lastSelected = null
184                                 viewModel.isSelected
185                             }
186                             .collect { isSelected ->
187                                 val shouldAnimate =
188                                     lastSelected != null && lastSelected != isSelected
189                                 if (shouldAnimate) {
190                                     animatedSelection(
191                                         backgroundView = backgroundView,
192                                         isSelected = isSelected,
193                                         animationSpec = animationSpec,
194                                     )
195                                 } else {
196                                     backgroundView.setProgress(if (isSelected) 1f else 0f)
197                                 }
198 
199                                 view.isSelected = isSelected
200                                 lastSelected = isSelected
201                             }
202                     }
203 
204                     launch {
205                         viewModel.onClicked.collect { onClicked ->
206                             view.setOnClickListener(
207                                 if (onClicked != null) {
208                                     View.OnClickListener { onClicked.invoke() }
209                                 } else {
210                                     null
211                                 }
212                             )
213                         }
214                     }
215                 }
216             }
217 
218         return DisposableHandle { job.cancel() }
219     }
220 
221     private fun animatedSelection(
222         backgroundView: OptionItemBackground,
223         isSelected: Boolean,
224         animationSpec: AnimationSpec,
225     ) {
226         if (isSelected) {
227             val springForce =
228                 SpringForce().apply {
229                     stiffness = SpringForce.STIFFNESS_MEDIUM
230                     dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY
231                     finalPosition = 1f
232                 }
233 
234             SpringAnimation(backgroundView, SpringAnimation.SCALE_X, 1f)
235                 .apply {
236                     setStartVelocity(5f)
237                     spring = springForce
238                 }
239                 .start()
240 
241             SpringAnimation(backgroundView, SpringAnimation.SCALE_Y, 1f)
242                 .apply {
243                     setStartVelocity(5f)
244                     spring = springForce
245                 }
246                 .start()
247 
248             ValueAnimator.ofFloat(0f, 1f)
249                 .apply {
250                     duration = animationSpec.durationMs
251                     addUpdateListener {
252                         val progress = it.animatedValue as Float
253                         backgroundView.setProgress(progress)
254                     }
255                 }
256                 .start()
257         } else {
258             ValueAnimator.ofFloat(1f, 0f)
259                 .apply {
260                     duration = animationSpec.durationMs
261                     addUpdateListener {
262                         val progress = it.animatedValue as Float
263                         backgroundView.setProgress(progress)
264                     }
265                 }
266                 .start()
267         }
268     }
269 
270     data class AnimationSpec(
271         /** Opacity of the option when it's enabled. */
272         val enabledAlpha: Float = 1f,
273         /** Opacity of the option background when it's disabled. */
274         val disabledBackgroundAlpha: Float = 0.5f,
275         /** Opacity of the option foreground when it's disabled. */
276         val disabledForegroundAlpha: Float = 0.5f,
277         /** Opacity of the option text when it's disabled. */
278         val disabledTextAlpha: Float = 0.61f,
279         /** Duration of the animation, in milliseconds. */
280         val durationMs: Long = 333L,
281     )
282 }
283