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