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 package com.android.systemui.volume.dialog.ringer.ui.binder
18 
19 import android.animation.ArgbEvaluator
20 import android.graphics.drawable.GradientDrawable
21 import android.view.LayoutInflater
22 import android.view.View
23 import android.widget.ImageButton
24 import androidx.annotation.LayoutRes
25 import androidx.compose.ui.util.fastForEachIndexed
26 import androidx.constraintlayout.motion.widget.MotionLayout
27 import androidx.constraintlayout.widget.ConstraintSet
28 import androidx.dynamicanimation.animation.DynamicAnimation
29 import androidx.dynamicanimation.animation.FloatValueHolder
30 import androidx.dynamicanimation.animation.SpringAnimation
31 import androidx.dynamicanimation.animation.SpringForce
32 import com.android.internal.R as internalR
33 import com.android.settingslib.Utils
34 import com.android.systemui.res.R
35 import com.android.systemui.util.children
36 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
37 import com.android.systemui.volume.dialog.ringer.ui.util.VolumeDialogRingerDrawerTransitionListener
38 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonUiModel
39 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonViewModel
40 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerDrawerState
41 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModel
42 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModelState
43 import com.android.systemui.volume.dialog.ringer.ui.viewmodel.VolumeDialogRingerDrawerViewModel
44 import com.android.systemui.volume.dialog.ui.utils.suspendAnimate
45 import javax.inject.Inject
46 import kotlin.properties.Delegates
47 import kotlinx.coroutines.CoroutineScope
48 import kotlinx.coroutines.coroutineScope
49 import kotlinx.coroutines.flow.launchIn
50 import kotlinx.coroutines.flow.onEach
51 import kotlinx.coroutines.launch
52 
53 private const val CLOSE_DRAWER_DELAY = 300L
54 
55 @VolumeDialogScope
56 class VolumeDialogRingerViewBinder
57 @Inject
58 constructor(private val viewModel: VolumeDialogRingerDrawerViewModel) {
59     private val roundnessSpringForce =
60         SpringForce(0F).apply {
61             stiffness = 800F
62             dampingRatio = 0.6F
63         }
64     private val colorSpringForce =
65         SpringForce(0F).apply {
66             stiffness = 3800F
67             dampingRatio = 1F
68         }
69     private val rgbEvaluator = ArgbEvaluator()
70 
71     fun CoroutineScope.bind(view: View) {
72         val volumeDialogBackgroundView = view.requireViewById<View>(R.id.volume_dialog_background)
73         val drawerContainer = view.requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
74         val unselectedButtonUiModel = RingerButtonUiModel.getUnselectedButton(view.context)
75         val selectedButtonUiModel = RingerButtonUiModel.getSelectedButton(view.context)
76         val volumeDialogBgSmallRadius =
77             view.context.resources.getDimensionPixelSize(
78                 R.dimen.volume_dialog_background_square_corner_radius
79             )
80         val volumeDialogBgFullRadius =
81             view.context.resources.getDimensionPixelSize(
82                 R.dimen.volume_dialog_background_corner_radius
83             )
84         var backgroundAnimationProgress: Float by
85             Delegates.observable(0F) { _, _, progress ->
86                 volumeDialogBackgroundView.applyCorners(
87                     fullRadius = volumeDialogBgFullRadius,
88                     diff = volumeDialogBgFullRadius - volumeDialogBgSmallRadius,
89                     progress,
90                 )
91             }
92         val ringerDrawerTransitionListener = VolumeDialogRingerDrawerTransitionListener {
93             backgroundAnimationProgress = it
94         }
95         drawerContainer.setTransitionListener(ringerDrawerTransitionListener)
96         volumeDialogBackgroundView.background = volumeDialogBackgroundView.background.mutate()
97         viewModel.ringerViewModel
98             .onEach { ringerState ->
99                 when (ringerState) {
100                     is RingerViewModelState.Available -> {
101                         val uiModel = ringerState.uiModel
102 
103                         // Set up view background and visibility
104                         drawerContainer.visibility = View.VISIBLE
105                         when (uiModel.drawerState) {
106                             is RingerDrawerState.Initial -> {
107                                 drawerContainer.animateAndBindDrawerButtons(
108                                     viewModel,
109                                     uiModel,
110                                     selectedButtonUiModel,
111                                     unselectedButtonUiModel,
112                                 )
113                                 ringerDrawerTransitionListener.setProgressChangeEnabled(true)
114                                 drawerContainer.closeDrawer(uiModel.currentButtonIndex)
115                             }
116 
117                             is RingerDrawerState.Closed -> {
118                                 if (
119                                     uiModel.selectedButton.ringerMode ==
120                                         uiModel.drawerState.currentMode
121                                 ) {
122                                     drawerContainer.animateAndBindDrawerButtons(
123                                         viewModel,
124                                         uiModel,
125                                         selectedButtonUiModel,
126                                         unselectedButtonUiModel,
127                                         onProgressChanged = { progress, isReverse ->
128                                             // Let's make button progress when switching matches
129                                             // motionLayout transition progress. When full radius,
130                                             // progress is 0.0. When small radius, progress is 1.0.
131                                             backgroundAnimationProgress =
132                                                 if (isReverse) {
133                                                     1F - progress
134                                                 } else {
135                                                     progress
136                                                 }
137                                         },
138                                     ) {
139                                         if (
140                                             uiModel.currentButtonIndex ==
141                                                 uiModel.availableButtons.size - 1
142                                         ) {
143                                             ringerDrawerTransitionListener.setProgressChangeEnabled(
144                                                 false
145                                             )
146                                         } else {
147                                             ringerDrawerTransitionListener.setProgressChangeEnabled(
148                                                 true
149                                             )
150                                         }
151                                         drawerContainer.closeDrawer(uiModel.currentButtonIndex)
152                                     }
153                                 }
154                             }
155 
156                             is RingerDrawerState.Open -> {
157                                 drawerContainer.animateAndBindDrawerButtons(
158                                     viewModel,
159                                     uiModel,
160                                     selectedButtonUiModel,
161                                     unselectedButtonUiModel,
162                                 )
163                                 // Open drawer
164                                 if (
165                                     uiModel.currentButtonIndex == uiModel.availableButtons.size - 1
166                                 ) {
167                                     ringerDrawerTransitionListener.setProgressChangeEnabled(false)
168                                 } else {
169                                     ringerDrawerTransitionListener.setProgressChangeEnabled(true)
170                                 }
171                                 drawerContainer.transitionToState(
172                                     R.id.volume_dialog_ringer_drawer_open
173                                 )
174                                 volumeDialogBackgroundView.background =
175                                     volumeDialogBackgroundView.background.mutate()
176                             }
177                         }
178                     }
179 
180                     is RingerViewModelState.Unavailable -> {
181                         drawerContainer.visibility = View.GONE
182                         volumeDialogBackgroundView.setBackgroundResource(
183                             R.drawable.volume_dialog_background
184                         )
185                     }
186                 }
187             }
188             .launchIn(this)
189     }
190 
191     private suspend fun MotionLayout.animateAndBindDrawerButtons(
192         viewModel: VolumeDialogRingerDrawerViewModel,
193         uiModel: RingerViewModel,
194         selectedButtonUiModel: RingerButtonUiModel,
195         unselectedButtonUiModel: RingerButtonUiModel,
196         onProgressChanged: (Float, Boolean) -> Unit = { _, _ -> },
197         onAnimationEnd: Runnable? = null,
198     ) {
199         ensureChildCount(R.layout.volume_ringer_button, uiModel.availableButtons.size)
200         if (
201             uiModel.drawerState is RingerDrawerState.Closed &&
202                 uiModel.drawerState.currentMode != uiModel.drawerState.previousMode
203         ) {
204             val count = uiModel.availableButtons.size
205             val selectedButton =
206                 getChildAt(count - uiModel.currentButtonIndex - 1)
207                     .requireViewById<ImageButton>(R.id.volume_drawer_button)
208             val previousIndex =
209                 uiModel.availableButtons.indexOfFirst {
210                     it?.ringerMode == uiModel.drawerState.previousMode
211                 }
212             val unselectedButton =
213                 getChildAt(count - previousIndex - 1)
214                     .requireViewById<ImageButton>(R.id.volume_drawer_button)
215 
216             // On roundness animation end.
217             val roundnessAnimationEndListener =
218                 DynamicAnimation.OnAnimationEndListener { _, _, _, _ ->
219                     postDelayed(
220                         { bindButtons(viewModel, uiModel, onAnimationEnd, isAnimated = true) },
221                         CLOSE_DRAWER_DELAY,
222                     )
223                 }
224             // We only need to execute on roundness animation end and volume dialog background
225             // progress update once because these changes should be applied once on volume dialog
226             // background and ringer drawer views.
227             selectedButton.animateTo(
228                 selectedButtonUiModel,
229                 if (uiModel.currentButtonIndex == count - 1) {
230                     onProgressChanged
231                 } else {
232                     { _, _ -> }
233                 },
234                 roundnessAnimationEndListener,
235             )
236             unselectedButton.animateTo(
237                 unselectedButtonUiModel,
238                 if (previousIndex == count - 1) {
239                     onProgressChanged
240                 } else {
241                     { _, _ -> }
242                 },
243             )
244         } else {
245             bindButtons(viewModel, uiModel, onAnimationEnd)
246         }
247     }
248 
249     private fun MotionLayout.bindButtons(
250         viewModel: VolumeDialogRingerDrawerViewModel,
251         uiModel: RingerViewModel,
252         onAnimationEnd: Runnable? = null,
253         isAnimated: Boolean = false,
254     ) {
255         val count = uiModel.availableButtons.size
256         uiModel.availableButtons.fastForEachIndexed { index, ringerButton ->
257             ringerButton?.let {
258                 val view = getChildAt(count - index - 1)
259                 val isOpen = uiModel.drawerState is RingerDrawerState.Open
260                 if (index == uiModel.currentButtonIndex) {
261                     view.bindDrawerButton(
262                         if (isOpen) it else uiModel.selectedButton,
263                         viewModel,
264                         isOpen,
265                         isSelected = true,
266                         isAnimated = isAnimated,
267                     )
268                 } else {
269                     view.bindDrawerButton(it, viewModel, isOpen, isAnimated = isAnimated)
270                 }
271             }
272         }
273         onAnimationEnd?.run()
274     }
275 
276     private fun View.bindDrawerButton(
277         buttonViewModel: RingerButtonViewModel,
278         viewModel: VolumeDialogRingerDrawerViewModel,
279         isOpen: Boolean,
280         isSelected: Boolean = false,
281         isAnimated: Boolean = false,
282     ) {
283         val ringerContentDesc = context.getString(buttonViewModel.contentDescriptionResId)
284         with(requireViewById<ImageButton>(R.id.volume_drawer_button)) {
285             setImageResource(buttonViewModel.imageResId)
286             contentDescription =
287                 if (isSelected && !isOpen) {
288                     context.getString(
289                         R.string.volume_ringer_drawer_closed_content_description,
290                         ringerContentDesc,
291                     )
292                 } else {
293                     ringerContentDesc
294                 }
295             if (isSelected && !isAnimated) {
296                 setBackgroundResource(R.drawable.volume_drawer_selection_bg)
297                 setColorFilter(
298                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary)
299                 )
300                 background = background.mutate()
301             } else if (!isAnimated) {
302                 setBackgroundResource(R.drawable.volume_ringer_item_bg)
303                 setColorFilter(
304                     Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface)
305                 )
306                 background = background.mutate()
307             }
308             setOnClickListener {
309                 viewModel.onRingerButtonClicked(buttonViewModel.ringerMode, isSelected)
310             }
311         }
312     }
313 
314     private fun MotionLayout.ensureChildCount(@LayoutRes viewLayoutId: Int, count: Int) {
315         val childCountDelta = childCount - count
316         when {
317             childCountDelta > 0 -> {
318                 removeViews(0, childCountDelta)
319             }
320             childCountDelta < 0 -> {
321                 val inflater = LayoutInflater.from(context)
322                 repeat(-childCountDelta) {
323                     inflater.inflate(viewLayoutId, this, true)
324                     getChildAt(childCount - 1).id = View.generateViewId()
325                 }
326                 cloneConstraintSet(R.id.volume_dialog_ringer_drawer_open)
327                     .adjustOpenConstraintsForDrawer(this)
328             }
329         }
330     }
331 
332     private fun MotionLayout.closeDrawer(selectedIndex: Int) {
333         setTransition(R.id.close_to_open_transition)
334         cloneConstraintSet(R.id.volume_dialog_ringer_drawer_close)
335             .adjustClosedConstraintsForDrawer(selectedIndex, this)
336         transitionToState(R.id.volume_dialog_ringer_drawer_close)
337     }
338 
339     private fun ConstraintSet.adjustOpenConstraintsForDrawer(motionLayout: MotionLayout) {
340         motionLayout.children.forEachIndexed { index, button ->
341             setButtonPositionConstraints(motionLayout, index, button)
342             setAlpha(button.id, 1.0F)
343             constrainWidth(
344                 button.id,
345                 motionLayout.context.resources.getDimensionPixelSize(
346                     R.dimen.volume_dialog_ringer_drawer_button_size
347                 ),
348             )
349             constrainHeight(
350                 button.id,
351                 motionLayout.context.resources.getDimensionPixelSize(
352                     R.dimen.volume_dialog_ringer_drawer_button_size
353                 ),
354             )
355             if (index != motionLayout.childCount - 1) {
356                 setMargin(
357                     button.id,
358                     ConstraintSet.BOTTOM,
359                     motionLayout.context.resources.getDimensionPixelSize(
360                         R.dimen.volume_dialog_components_spacing
361                     ),
362                 )
363             }
364         }
365         motionLayout.updateState(R.id.volume_dialog_ringer_drawer_open, this)
366     }
367 
368     private fun ConstraintSet.adjustClosedConstraintsForDrawer(
369         selectedIndex: Int,
370         motionLayout: MotionLayout,
371     ) {
372         motionLayout.children.forEachIndexed { index, button ->
373             setButtonPositionConstraints(motionLayout, index, button)
374             constrainWidth(
375                 button.id,
376                 motionLayout.context.resources.getDimensionPixelSize(
377                     R.dimen.volume_dialog_ringer_drawer_button_size
378                 ),
379             )
380             if (selectedIndex != motionLayout.childCount - index - 1) {
381                 setAlpha(button.id, 0.0F)
382                 constrainHeight(button.id, 0)
383                 setMargin(button.id, ConstraintSet.BOTTOM, 0)
384             } else {
385                 setAlpha(button.id, 1.0F)
386                 constrainHeight(
387                     button.id,
388                     motionLayout.context.resources.getDimensionPixelSize(
389                         R.dimen.volume_dialog_ringer_drawer_button_size
390                     ),
391                 )
392             }
393         }
394         motionLayout.updateState(R.id.volume_dialog_ringer_drawer_close, this)
395     }
396 
397     private fun ConstraintSet.setButtonPositionConstraints(
398         motionLayout: MotionLayout,
399         index: Int,
400         button: View,
401     ) {
402         if (motionLayout.getChildAt(index - 1) == null) {
403             connect(button.id, ConstraintSet.TOP, motionLayout.id, ConstraintSet.TOP)
404         } else {
405             connect(
406                 button.id,
407                 ConstraintSet.TOP,
408                 motionLayout.getChildAt(index - 1).id,
409                 ConstraintSet.BOTTOM,
410             )
411         }
412 
413         if (motionLayout.getChildAt(index + 1) == null) {
414             connect(button.id, ConstraintSet.BOTTOM, motionLayout.id, ConstraintSet.BOTTOM)
415         } else {
416             connect(
417                 button.id,
418                 ConstraintSet.BOTTOM,
419                 motionLayout.getChildAt(index + 1).id,
420                 ConstraintSet.TOP,
421             )
422         }
423         connect(button.id, ConstraintSet.START, motionLayout.id, ConstraintSet.START)
424         connect(button.id, ConstraintSet.END, motionLayout.id, ConstraintSet.END)
425     }
426 
427     private suspend fun ImageButton.animateTo(
428         ringerButtonUiModel: RingerButtonUiModel,
429         onProgressChanged: (Float, Boolean) -> Unit = { _, _ -> },
430         roundnessAnimationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
431     ) {
432         val roundnessAnimation =
433             SpringAnimation(FloatValueHolder(0F)).setSpring(roundnessSpringForce)
434         val colorAnimation = SpringAnimation(FloatValueHolder(0F)).setSpring(colorSpringForce)
435         val radius = (background as GradientDrawable).cornerRadius
436         val cornerRadiusDiff =
437             ringerButtonUiModel.cornerRadius - (background as GradientDrawable).cornerRadius
438         val roundnessAnimationUpdateListener =
439             DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
440                 onProgressChanged(value, cornerRadiusDiff > 0F)
441                 (background as GradientDrawable).cornerRadius = radius + value * cornerRadiusDiff
442                 background.invalidateSelf()
443             }
444         val colorAnimationUpdateListener =
445             DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
446                 val currentIconColor =
447                     rgbEvaluator.evaluate(
448                         value.coerceIn(0F, 1F),
449                         imageTintList?.colors?.first(),
450                         ringerButtonUiModel.tintColor,
451                     ) as Int
452                 val currentBgColor =
453                     rgbEvaluator.evaluate(
454                         value.coerceIn(0F, 1F),
455                         (background as GradientDrawable).color?.colors?.get(0),
456                         ringerButtonUiModel.backgroundColor,
457                     ) as Int
458 
459                 (background as GradientDrawable).setColor(currentBgColor)
460                 background.invalidateSelf()
461                 setColorFilter(currentIconColor)
462             }
463         coroutineScope {
464             launch { colorAnimation.suspendAnimate(colorAnimationUpdateListener) }
465             roundnessAnimation.suspendAnimate(
466                 roundnessAnimationUpdateListener,
467                 roundnessAnimationEndListener,
468             )
469         }
470     }
471 
472     private fun View.applyCorners(fullRadius: Int, diff: Int, progress: Float) {
473         (background as GradientDrawable).cornerRadius = fullRadius - progress * diff
474         background.invalidateSelf()
475     }
476 }
477