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