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