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 package com.android.customization.picker.clock.ui.view 17 18 import android.content.Context 19 import android.content.res.ColorStateList 20 import android.content.res.Resources 21 import android.util.AttributeSet 22 import android.util.TypedValue 23 import android.view.LayoutInflater 24 import android.view.View 25 import android.view.ViewGroup 26 import android.widget.FrameLayout 27 import androidx.constraintlayout.helper.widget.Carousel 28 import androidx.constraintlayout.motion.widget.MotionLayout 29 import androidx.constraintlayout.widget.ConstraintSet 30 import androidx.core.view.doOnPreDraw 31 import androidx.core.view.get 32 import androidx.core.view.isNotEmpty 33 import androidx.core.view.isVisible 34 import com.android.customization.picker.clock.shared.ClockSize 35 import com.android.customization.picker.clock.ui.viewmodel.ClockCarouselItemViewModel 36 import com.android.systemui.plugins.clocks.ClockController 37 import com.android.themepicker.R 38 import com.android.wallpaper.picker.FixedWidthDisplayRatioFrameLayout 39 import java.lang.Float.max 40 41 class ClockCarouselView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) { 42 43 val carousel: Carousel 44 private val motionLayout: MotionLayout 45 private val clockViewScale: Float 46 private lateinit var adapter: ClockCarouselAdapter 47 private lateinit var clockViewFactory: ClockViewFactory 48 private var toCenterClockController: ClockController? = null 49 private var offCenterClockController: ClockController? = null 50 private var toCenterClockScaleView: View? = null 51 private var offCenterClockScaleView: View? = null 52 private var toCenterClockHostView: ClockHostView? = null 53 private var offCenterClockHostView: ClockHostView? = null 54 private var toCenterCardView: View? = null 55 private var offCenterCardView: View? = null 56 57 init { 58 val clockCarousel = LayoutInflater.from(context).inflate(R.layout.clock_carousel, this) 59 carousel = clockCarousel.requireViewById(R.id.carousel) 60 motionLayout = clockCarousel.requireViewById(R.id.motion_container) 61 motionLayout.isVisible = false 62 motionLayout.contentDescription = context.getString(R.string.custom_clocks_label) 63 clockViewScale = 64 TypedValue().let { 65 resources.getValue(R.dimen.clock_carousel_scale, it, true) 66 it.float 67 } 68 } 69 70 /** 71 * Make sure to set [clockViewFactory] before calling any functions from [ClockCarouselView]. 72 */ 73 fun setClockViewFactory(factory: ClockViewFactory) { 74 clockViewFactory = factory 75 } 76 77 // This function is for the custom accessibility action to trigger a transition to the next 78 // carousel item. If the current item is the last item in the carousel, the next item 79 // will be the first item. 80 fun transitionToNext() { 81 if (carousel.count != 0) { 82 val index = (carousel.currentIndex + 1) % carousel.count 83 carousel.jumpToIndex(index) 84 // Explicitly called this since using transitionToIndex(index) leads to 85 // race-condition between announcement of content description of the correct clock-face 86 // and the selection of clock face itself 87 adapter.onNewItem(index) 88 } 89 } 90 91 // This function is for the custom accessibility action to trigger a transition to 92 // the previous carousel item. If the current item is the first item in the carousel, 93 // the previous item will be the last item. 94 fun transitionToPrevious() { 95 if (carousel.count != 0) { 96 val index = (carousel.currentIndex + carousel.count - 1) % carousel.count 97 carousel.jumpToIndex(index) 98 // Explicitly called this since using transitionToIndex(index) leads to 99 // race-condition between announcement of content description of the correct clock-face 100 // and the selection of clock face itself 101 adapter.onNewItem(index) 102 } 103 } 104 105 fun scrollToNext() { 106 if ( 107 carousel.count <= 1 || 108 (!carousel.isInfinite && carousel.currentIndex == carousel.count - 1) 109 ) { 110 // No need to scroll if the count is equal or less than 1 111 return 112 } 113 if (motionLayout.currentState == R.id.start) { 114 motionLayout.transitionToState(R.id.next, TRANSITION_DURATION) 115 } 116 } 117 118 fun scrollToPrevious() { 119 if (carousel.count <= 1 || (!carousel.isInfinite && carousel.currentIndex == 0)) { 120 // No need to scroll if the count is equal or less than 1 121 return 122 } 123 if (motionLayout.currentState == R.id.start) { 124 motionLayout.transitionToState(R.id.previous, TRANSITION_DURATION) 125 } 126 } 127 128 fun getContentDescription(index: Int): String { 129 return adapter.getContentDescription(index, resources) 130 } 131 132 fun setUpClockCarouselView( 133 clockSize: ClockSize, 134 clocks: List<ClockCarouselItemViewModel>, 135 onClockSelected: (clock: ClockCarouselItemViewModel) -> Unit, 136 isTwoPaneAndSmallWidth: Boolean, 137 ) { 138 if (clocks.isEmpty()) { 139 // Hide the carousel if clock list is empty 140 motionLayout.isVisible = false 141 return 142 } 143 if (isTwoPaneAndSmallWidth) { 144 overrideScreenPreviewWidth() 145 } 146 147 adapter = 148 ClockCarouselAdapter( 149 clockViewScale, 150 clockSize, 151 clocks, 152 clockViewFactory, 153 onClockSelected, 154 ) 155 carousel.isInfinite = clocks.size >= MIN_CLOCKS_TO_ENABLE_INFINITE_CAROUSEL 156 carousel.setAdapter(adapter) 157 val indexOfSelectedClock = 158 clocks 159 .indexOfFirst { it.isSelected } 160 // If not found, default to the first clock as selected: 161 .takeIf { it != -1 } ?: 0 162 carousel.jumpToIndex(indexOfSelectedClock) 163 motionLayout.setTransitionListener( 164 object : MotionLayout.TransitionListener { 165 166 override fun onTransitionStarted( 167 motionLayout: MotionLayout?, 168 startId: Int, 169 endId: Int, 170 ) { 171 if (motionLayout == null) { 172 return 173 } 174 when (clockSize) { 175 ClockSize.DYNAMIC -> prepareDynamicClockView(motionLayout, endId) 176 ClockSize.SMALL -> prepareSmallClockView(motionLayout, endId) 177 } 178 prepareCardView(motionLayout, endId) 179 setCarouselItemAnimationState(true) 180 } 181 182 override fun onTransitionChange( 183 motionLayout: MotionLayout?, 184 startId: Int, 185 endId: Int, 186 progress: Float, 187 ) { 188 when (clockSize) { 189 ClockSize.DYNAMIC -> onDynamicClockViewTransition(progress) 190 ClockSize.SMALL -> onSmallClockViewTransition(progress) 191 } 192 onCardViewTransition(progress) 193 } 194 195 override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { 196 setCarouselItemAnimationState(currentId == R.id.start) 197 } 198 199 private fun prepareDynamicClockView(motionLayout: MotionLayout, endId: Int) { 200 val scalingDownClockId = adapter.clocks[carousel.currentIndex].clockId 201 val scalingUpIdx = 202 if (endId == R.id.next) (carousel.currentIndex + 1) % adapter.count() 203 else (carousel.currentIndex - 1 + adapter.count()) % adapter.count() 204 val scalingUpClockId = adapter.clocks[scalingUpIdx].clockId 205 offCenterClockController = clockViewFactory.getController(scalingDownClockId) 206 toCenterClockController = clockViewFactory.getController(scalingUpClockId) 207 offCenterClockScaleView = motionLayout.findViewById(R.id.clock_scale_view_2) 208 toCenterClockScaleView = 209 motionLayout.findViewById( 210 if (endId == R.id.next) R.id.clock_scale_view_3 211 else R.id.clock_scale_view_1 212 ) 213 } 214 215 private fun prepareSmallClockView(motionLayout: MotionLayout, endId: Int) { 216 offCenterClockHostView = motionLayout.findViewById(R.id.clock_host_view_2) 217 toCenterClockHostView = 218 motionLayout.findViewById( 219 if (endId == R.id.next) R.id.clock_host_view_3 220 else R.id.clock_host_view_1 221 ) 222 } 223 224 private fun prepareCardView(motionLayout: MotionLayout, endId: Int) { 225 offCenterCardView = motionLayout.findViewById(R.id.item_card_2) 226 toCenterCardView = 227 motionLayout.findViewById( 228 if (endId == R.id.next) R.id.item_card_3 else R.id.item_card_1 229 ) 230 } 231 232 private fun onCardViewTransition(progress: Float) { 233 offCenterCardView?.alpha = getShowingAlpha(progress) 234 toCenterCardView?.alpha = getHidingAlpha(progress) 235 } 236 237 private fun onDynamicClockViewTransition(progress: Float) { 238 offCenterClockController 239 ?.largeClock 240 ?.animations 241 ?.onPickerCarouselSwiping(1 - progress) 242 toCenterClockController 243 ?.largeClock 244 ?.animations 245 ?.onPickerCarouselSwiping(progress) 246 val scalingDownScale = getScalingDownScale(progress, clockViewScale) 247 val scalingUpScale = getScalingUpScale(progress, clockViewScale) 248 offCenterClockScaleView?.scaleX = scalingDownScale 249 offCenterClockScaleView?.scaleY = scalingDownScale 250 toCenterClockScaleView?.scaleX = scalingUpScale 251 toCenterClockScaleView?.scaleY = scalingUpScale 252 } 253 254 private fun onSmallClockViewTransition(progress: Float) { 255 val offCenterClockHostView = offCenterClockHostView ?: return 256 val toCenterClockHostView = toCenterClockHostView ?: return 257 val offCenterClockFrame = 258 if (offCenterClockHostView.isNotEmpty()) { 259 offCenterClockHostView[0] 260 } else { 261 null 262 } ?: return 263 val toCenterClockFrame = 264 if (toCenterClockHostView.isNotEmpty()) { 265 toCenterClockHostView[0] 266 } else { 267 null 268 } ?: return 269 offCenterClockHostView.doOnPreDraw { 270 it.pivotX = 271 progress * it.width / 2 + (1 - progress) * getCenteredHostViewPivotX(it) 272 it.pivotY = progress * it.height / 2 273 } 274 toCenterClockHostView.doOnPreDraw { 275 it.pivotX = 276 (1 - progress) * it.width / 2 + progress * getCenteredHostViewPivotX(it) 277 it.pivotY = (1 - progress) * it.height / 2 278 } 279 offCenterClockFrame.translationX = 280 getTranslationDistance( 281 offCenterClockHostView.width, 282 offCenterClockFrame.width, 283 offCenterClockFrame.left, 284 ) * progress 285 offCenterClockFrame.translationY = 286 getTranslationDistance( 287 offCenterClockHostView.height, 288 offCenterClockFrame.height, 289 offCenterClockFrame.top, 290 ) * progress 291 toCenterClockFrame.translationX = 292 getTranslationDistance( 293 toCenterClockHostView.width, 294 toCenterClockFrame.width, 295 toCenterClockFrame.left, 296 ) * (1 - progress) 297 toCenterClockFrame.translationY = 298 getTranslationDistance( 299 toCenterClockHostView.height, 300 toCenterClockFrame.height, 301 toCenterClockFrame.top, 302 ) * (1 - progress) 303 } 304 305 private fun setCarouselItemAnimationState(isStart: Boolean) { 306 when (clockSize) { 307 ClockSize.DYNAMIC -> onDynamicClockViewTransition(if (isStart) 0f else 1f) 308 ClockSize.SMALL -> onSmallClockViewTransition(if (isStart) 0f else 1f) 309 } 310 onCardViewTransition(if (isStart) 0f else 1f) 311 } 312 313 override fun onTransitionTrigger( 314 motionLayout: MotionLayout?, 315 triggerId: Int, 316 positive: Boolean, 317 progress: Float, 318 ) {} 319 } 320 ) 321 motionLayout.isVisible = true 322 } 323 324 fun setSelectedClockIndex(index: Int) { 325 // 1. setUpClockCarouselView() can possibly not be called before setSelectedClockIndex(). 326 // We need to check if index out of bound. 327 // 2. jumpToIndex() to the same position can cause the views unnecessarily populate again. 328 // We only call jumpToIndex when the index is different from the current carousel. 329 if (index < carousel.count && index != carousel.currentIndex) { 330 carousel.jumpToIndex(index) 331 } 332 } 333 334 fun setCarouselCardColor(color: Int) { 335 itemViewIds.forEach { id -> 336 val cardViewId = getClockCardViewId(id) 337 cardViewId?.let { 338 val cardView = motionLayout.requireViewById<View>(it) 339 cardView.backgroundTintList = ColorStateList.valueOf(color) 340 } 341 } 342 } 343 344 private fun overrideScreenPreviewWidth() { 345 val overrideWidth = 346 context.resources.getDimensionPixelSize( 347 com.android.wallpaper.R.dimen.screen_preview_width_for_2_pane_small_width 348 ) 349 itemViewIds.forEach { id -> 350 val itemView = motionLayout.requireViewById<FrameLayout>(id) 351 val itemViewLp = itemView.layoutParams 352 itemViewLp.width = overrideWidth 353 itemView.layoutParams = itemViewLp 354 355 getClockScaleViewId(id)?.let { 356 val scaleView = motionLayout.requireViewById<FixedWidthDisplayRatioFrameLayout>(it) 357 val scaleViewLp = scaleView.layoutParams 358 scaleViewLp.width = overrideWidth 359 scaleView.layoutParams = scaleViewLp 360 } 361 } 362 363 val previousConstraintSet = motionLayout.getConstraintSet(R.id.previous) 364 val startConstraintSet = motionLayout.getConstraintSet(R.id.start) 365 val nextConstraintSet = motionLayout.getConstraintSet(R.id.next) 366 val constraintSetList = 367 listOf<ConstraintSet>(previousConstraintSet, startConstraintSet, nextConstraintSet) 368 constraintSetList.forEach { constraintSet -> 369 itemViewIds.forEach { id -> 370 constraintSet.getConstraint(id)?.let { constraint -> 371 val layout = constraint.layout 372 if ( 373 constraint.layout.mWidth == 374 context.resources.getDimensionPixelSize( 375 com.android.wallpaper.R.dimen.screen_preview_width 376 ) 377 ) { 378 layout.mWidth = overrideWidth 379 } 380 if ( 381 constraint.layout.widthMax == 382 context.resources.getDimensionPixelSize( 383 com.android.wallpaper.R.dimen.screen_preview_width 384 ) 385 ) { 386 layout.widthMax = overrideWidth 387 } 388 } 389 } 390 } 391 } 392 393 private class ClockCarouselAdapter( 394 val clockViewScale: Float, 395 val clockSize: ClockSize, 396 val clocks: List<ClockCarouselItemViewModel>, 397 private val clockViewFactory: ClockViewFactory, 398 private val onClockSelected: (clock: ClockCarouselItemViewModel) -> Unit, 399 ) : Carousel.Adapter { 400 401 // This map is used to eagerly save the translation X and Y of each small clock view, so 402 // that the next time we need it, we do not need to wait for onPreDraw to obtain the 403 // translation X and Y. 404 // This is to solve the issue that when Fragment transition triggers another attach of the 405 // view for animation purposes. We need to obtain the translation X and Y quick enough so 406 // that the outgoing carousel view that shows this the small clock views are correctly 407 // positioned. 408 private val smallClockTranslationMap: MutableMap<String, Pair<Float, Float>> = 409 mutableMapOf() 410 411 fun getContentDescription(index: Int, resources: Resources): String { 412 return clocks[index].contentDescription 413 } 414 415 override fun count(): Int { 416 return clocks.size 417 } 418 419 override fun populate(view: View?, index: Int) { 420 val viewRoot = view as? ViewGroup ?: return 421 val cardView = 422 getClockCardViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? View } 423 ?: return 424 val clockScaleView = 425 getClockScaleViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? View } 426 ?: return 427 val clockHostView = 428 getClockHostViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? ClockHostView } 429 ?: return 430 val clockId = clocks[index].clockId 431 432 // Add the clock view to the clock host view 433 clockHostView.removeAllViews() 434 val clockView = 435 when (clockSize) { 436 ClockSize.DYNAMIC -> clockViewFactory.getLargeView(clockId) 437 ClockSize.SMALL -> clockViewFactory.getSmallView(clockId) 438 } 439 // The clock view might still be attached to an existing parent. Detach before adding to 440 // another parent. 441 (clockView.parent as? ViewGroup)?.removeView(clockView) 442 clockHostView.addView(clockView) 443 444 val isMiddleView = isMiddleView(viewRoot.id) 445 446 // Accessibility 447 viewRoot.contentDescription = getContentDescription(index, view.resources) 448 viewRoot.isSelected = isMiddleView 449 450 when (clockSize) { 451 ClockSize.DYNAMIC -> 452 initializeDynamicClockView(isMiddleView, clockScaleView, clockId, clockHostView) 453 ClockSize.SMALL -> 454 initializeSmallClockView(clockId, isMiddleView, clockHostView, clockView) 455 } 456 cardView.alpha = if (isMiddleView) 0f else 1f 457 } 458 459 private fun initializeDynamicClockView( 460 isMiddleView: Boolean, 461 clockScaleView: View, 462 clockId: String, 463 clockHostView: ClockHostView, 464 ) { 465 clockHostView.doOnPreDraw { 466 it.pivotX = it.width / 2F 467 it.pivotY = it.height / 2F 468 } 469 470 clockViewFactory.getController(clockId)?.let { controller -> 471 if (isMiddleView) { 472 clockScaleView.scaleX = 1f 473 clockScaleView.scaleY = 1f 474 controller.largeClock.animations.onPickerCarouselSwiping(1F) 475 } else { 476 clockScaleView.scaleX = clockViewScale 477 clockScaleView.scaleY = clockViewScale 478 controller.largeClock.animations.onPickerCarouselSwiping(0F) 479 } 480 } 481 } 482 483 private fun initializeSmallClockView( 484 clockId: String, 485 isMiddleView: Boolean, 486 clockHostView: ClockHostView, 487 clockView: View, 488 ) { 489 smallClockTranslationMap[clockId]?.let { 490 // If isMiddleView, the translation X and Y should both be 0 491 if (!isMiddleView) { 492 clockView.translationX = it.first 493 clockView.translationY = it.second 494 } 495 } 496 clockHostView.doOnPreDraw { 497 if (isMiddleView) { 498 it.pivotX = getCenteredHostViewPivotX(it) 499 it.pivotY = 0F 500 clockView.translationX = 0F 501 clockView.translationY = 0F 502 } else { 503 it.pivotX = it.width / 2F 504 it.pivotY = it.height / 2F 505 val translationX = 506 getTranslationDistance(clockHostView.width, clockView.width, clockView.left) 507 val translationY = 508 getTranslationDistance( 509 clockHostView.height, 510 clockView.height, 511 clockView.top, 512 ) 513 clockView.translationX = translationX 514 clockView.translationY = translationY 515 smallClockTranslationMap[clockId] = Pair(translationX, translationY) 516 } 517 } 518 } 519 520 override fun onNewItem(index: Int) { 521 onClockSelected.invoke(clocks[index]) 522 } 523 } 524 525 companion object { 526 // The carousel needs to have at least 5 different clock faces to be infinite 527 const val MIN_CLOCKS_TO_ENABLE_INFINITE_CAROUSEL = 5 528 const val TRANSITION_DURATION = 250 529 530 val itemViewIds = 531 listOf( 532 R.id.item_view_0, 533 R.id.item_view_1, 534 R.id.item_view_2, 535 R.id.item_view_3, 536 R.id.item_view_4, 537 ) 538 539 fun getScalingUpScale(progress: Float, clockViewScale: Float) = 540 clockViewScale + progress * (1f - clockViewScale) 541 542 fun getScalingDownScale(progress: Float, clockViewScale: Float) = 543 1f - progress * (1f - clockViewScale) 544 545 // This makes the card only starts to reveal in the last quarter of the trip so 546 // the card won't overlap the preview. 547 fun getShowingAlpha(progress: Float) = max(progress - 0.75f, 0f) * 4 548 549 // This makes the card starts to hide in the first quarter of the trip so the 550 // card won't overlap the preview. 551 fun getHidingAlpha(progress: Float) = max(1f - progress * 4, 0f) 552 553 fun getClockHostViewId(rootViewId: Int): Int? { 554 return when (rootViewId) { 555 R.id.item_view_0 -> R.id.clock_host_view_0 556 R.id.item_view_1 -> R.id.clock_host_view_1 557 R.id.item_view_2 -> R.id.clock_host_view_2 558 R.id.item_view_3 -> R.id.clock_host_view_3 559 R.id.item_view_4 -> R.id.clock_host_view_4 560 else -> null 561 } 562 } 563 564 fun getClockScaleViewId(rootViewId: Int): Int? { 565 return when (rootViewId) { 566 R.id.item_view_0 -> R.id.clock_scale_view_0 567 R.id.item_view_1 -> R.id.clock_scale_view_1 568 R.id.item_view_2 -> R.id.clock_scale_view_2 569 R.id.item_view_3 -> R.id.clock_scale_view_3 570 R.id.item_view_4 -> R.id.clock_scale_view_4 571 else -> null 572 } 573 } 574 575 fun getClockCardViewId(rootViewId: Int): Int? { 576 return when (rootViewId) { 577 R.id.item_view_0 -> R.id.item_card_0 578 R.id.item_view_1 -> R.id.item_card_1 579 R.id.item_view_2 -> R.id.item_card_2 580 R.id.item_view_3 -> R.id.item_card_3 581 R.id.item_view_4 -> R.id.item_card_4 582 else -> null 583 } 584 } 585 586 fun isMiddleView(rootViewId: Int): Boolean { 587 return rootViewId == R.id.item_view_2 588 } 589 590 fun getCenteredHostViewPivotX(hostView: View): Float { 591 return if (hostView.isLayoutRtl) hostView.width.toFloat() else 0F 592 } 593 594 private fun getTranslationDistance( 595 hostLength: Int, 596 frameLength: Int, 597 edgeDimen: Int, 598 ): Float { 599 return ((hostLength - frameLength) / 2 - edgeDimen).toFloat() 600 } 601 } 602 } 603