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.view.LayoutInflater 23 import android.view.View 24 import android.view.ViewGroup 25 import android.widget.FrameLayout 26 import androidx.constraintlayout.helper.widget.Carousel 27 import androidx.constraintlayout.motion.widget.MotionLayout 28 import androidx.constraintlayout.widget.ConstraintSet 29 import androidx.core.view.doOnPreDraw 30 import androidx.core.view.get 31 import androidx.core.view.isNotEmpty 32 import com.android.customization.picker.clock.shared.ClockSize 33 import com.android.customization.picker.clock.ui.viewmodel.ClockCarouselItemViewModel 34 import com.android.systemui.plugins.ClockController 35 import com.android.wallpaper.R 36 import com.android.wallpaper.picker.FixedWidthDisplayRatioFrameLayout 37 import java.lang.Float.max 38 39 class ClockCarouselView( 40 context: Context, 41 attrs: AttributeSet, 42 ) : 43 FrameLayout( 44 context, 45 attrs, 46 ) { 47 48 val carousel: Carousel 49 private val motionLayout: MotionLayout 50 private lateinit var adapter: ClockCarouselAdapter 51 private lateinit var clockViewFactory: ClockViewFactory 52 private var toCenterClockController: ClockController? = null 53 private var offCenterClockController: ClockController? = null 54 private var toCenterClockScaleView: View? = null 55 private var offCenterClockScaleView: View? = null 56 private var toCenterClockHostView: ClockHostView? = null 57 private var offCenterClockHostView: ClockHostView? = null 58 private var toCenterCardView: View? = null 59 private var offCenterCardView: View? = null 60 61 init { 62 val clockCarousel = LayoutInflater.from(context).inflate(R.layout.clock_carousel, this) 63 carousel = clockCarousel.requireViewById(R.id.carousel) 64 motionLayout = clockCarousel.requireViewById(R.id.motion_container) 65 motionLayout.contentDescription = context.getString(R.string.custom_clocks_label) 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 = ClockCarouselAdapter(clockSize, clocks, clockViewFactory, onClockSelected) 141 carousel.isInfinite = clocks.size >= MIN_CLOCKS_TO_ENABLE_INFINITE_CAROUSEL 142 carousel.setAdapter(adapter) 143 val indexOfSelectedClock = 144 clocks 145 .indexOfFirst { it.isSelected } 146 // If not found, default to the first clock as selected: 147 .takeIf { it != -1 } 148 ?: 0 149 carousel.jumpToIndex(indexOfSelectedClock) 150 motionLayout.setTransitionListener( 151 object : MotionLayout.TransitionListener { 152 153 override fun onTransitionStarted( 154 motionLayout: MotionLayout?, 155 startId: Int, 156 endId: Int 157 ) { 158 if (motionLayout == null) { 159 return 160 } 161 when (clockSize) { 162 ClockSize.DYNAMIC -> prepareDynamicClockView(motionLayout, endId) 163 ClockSize.SMALL -> prepareSmallClockView(motionLayout, endId) 164 } 165 prepareCardView(motionLayout, endId) 166 setCarouselItemAnimationState(true) 167 } 168 169 override fun onTransitionChange( 170 motionLayout: MotionLayout?, 171 startId: Int, 172 endId: Int, 173 progress: Float, 174 ) { 175 when (clockSize) { 176 ClockSize.DYNAMIC -> onDynamicClockViewTransition(progress) 177 ClockSize.SMALL -> onSmallClockViewTransition(progress) 178 } 179 onCardViewTransition(progress) 180 } 181 182 override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { 183 setCarouselItemAnimationState(currentId == R.id.start) 184 } 185 186 private fun prepareDynamicClockView(motionLayout: MotionLayout, endId: Int) { 187 val scalingDownClockId = adapter.clocks[carousel.currentIndex].clockId 188 val scalingUpIdx = 189 if (endId == R.id.next) (carousel.currentIndex + 1) % adapter.count() 190 else (carousel.currentIndex - 1 + adapter.count()) % adapter.count() 191 val scalingUpClockId = adapter.clocks[scalingUpIdx].clockId 192 offCenterClockController = clockViewFactory.getController(scalingDownClockId) 193 toCenterClockController = clockViewFactory.getController(scalingUpClockId) 194 offCenterClockScaleView = motionLayout.findViewById(R.id.clock_scale_view_2) 195 toCenterClockScaleView = 196 motionLayout.findViewById( 197 if (endId == R.id.next) R.id.clock_scale_view_3 198 else R.id.clock_scale_view_1 199 ) 200 } 201 202 private fun prepareSmallClockView(motionLayout: MotionLayout, endId: Int) { 203 offCenterClockHostView = motionLayout.findViewById(R.id.clock_host_view_2) 204 toCenterClockHostView = 205 motionLayout.findViewById( 206 if (endId == R.id.next) R.id.clock_host_view_3 207 else R.id.clock_host_view_1 208 ) 209 } 210 211 private fun prepareCardView(motionLayout: MotionLayout, endId: Int) { 212 offCenterCardView = motionLayout.findViewById(R.id.item_card_2) 213 toCenterCardView = 214 motionLayout.findViewById( 215 if (endId == R.id.next) R.id.item_card_3 else R.id.item_card_1 216 ) 217 } 218 219 private fun onCardViewTransition(progress: Float) { 220 offCenterCardView?.alpha = getShowingAlpha(progress) 221 toCenterCardView?.alpha = getHidingAlpha(progress) 222 } 223 224 private fun onDynamicClockViewTransition(progress: Float) { 225 offCenterClockController 226 ?.largeClock 227 ?.animations 228 ?.onPickerCarouselSwiping(1 - progress) 229 toCenterClockController 230 ?.largeClock 231 ?.animations 232 ?.onPickerCarouselSwiping(progress) 233 val scalingDownScale = getScalingDownScale(progress) 234 val scalingUpScale = getScalingUpScale(progress) 235 offCenterClockScaleView?.scaleX = scalingDownScale 236 offCenterClockScaleView?.scaleY = scalingDownScale 237 toCenterClockScaleView?.scaleX = scalingUpScale 238 toCenterClockScaleView?.scaleY = scalingUpScale 239 } 240 241 private fun onSmallClockViewTransition(progress: Float) { 242 val offCenterClockHostView = offCenterClockHostView ?: return 243 val toCenterClockHostView = toCenterClockHostView ?: return 244 val offCenterClockFrame = 245 if (offCenterClockHostView.isNotEmpty()) { 246 offCenterClockHostView[0] 247 } else { 248 null 249 } 250 ?: return 251 val toCenterClockFrame = 252 if (toCenterClockHostView.isNotEmpty()) { 253 toCenterClockHostView[0] 254 } else { 255 null 256 } 257 ?: return 258 offCenterClockHostView.doOnPreDraw { 259 it.pivotX = 260 progress * it.width / 2 + (1 - progress) * getCenteredHostViewPivotX(it) 261 it.pivotY = progress * it.height / 2 262 } 263 toCenterClockHostView.doOnPreDraw { 264 it.pivotX = 265 (1 - progress) * it.width / 2 + progress * getCenteredHostViewPivotX(it) 266 it.pivotY = (1 - progress) * it.height / 2 267 } 268 offCenterClockFrame.translationX = 269 getTranslationDistance( 270 offCenterClockHostView.width, 271 offCenterClockFrame.width, 272 offCenterClockFrame.left, 273 ) * progress 274 offCenterClockFrame.translationY = 275 getTranslationDistance( 276 offCenterClockHostView.height, 277 offCenterClockFrame.height, 278 offCenterClockFrame.top, 279 ) * progress 280 toCenterClockFrame.translationX = 281 getTranslationDistance( 282 toCenterClockHostView.width, 283 toCenterClockFrame.width, 284 toCenterClockFrame.left, 285 ) * (1 - progress) 286 toCenterClockFrame.translationY = 287 getTranslationDistance( 288 toCenterClockHostView.height, 289 toCenterClockFrame.height, 290 toCenterClockFrame.top, 291 ) * (1 - progress) 292 } 293 294 private fun setCarouselItemAnimationState(isStart: Boolean) { 295 when (clockSize) { 296 ClockSize.DYNAMIC -> onDynamicClockViewTransition(if (isStart) 0f else 1f) 297 ClockSize.SMALL -> onSmallClockViewTransition(if (isStart) 0f else 1f) 298 } 299 onCardViewTransition(if (isStart) 0f else 1f) 300 } 301 302 override fun onTransitionTrigger( 303 motionLayout: MotionLayout?, 304 triggerId: Int, 305 positive: Boolean, 306 progress: Float 307 ) {} 308 } 309 ) 310 } 311 312 fun setSelectedClockIndex( 313 index: Int, 314 ) { 315 // 1. setUpClockCarouselView() can possibly not be called before setSelectedClockIndex(). 316 // We need to check if index out of bound. 317 // 2. jumpToIndex() to the same position can cause the views unnecessarily populate again. 318 // We only call jumpToIndex when the index is different from the current carousel. 319 if (index < carousel.count && index != carousel.currentIndex) { 320 carousel.jumpToIndex(index) 321 } 322 } 323 324 fun setCarouselCardColor(color: Int) { 325 itemViewIds.forEach { id -> 326 val cardViewId = getClockCardViewId(id) 327 cardViewId?.let { 328 val cardView = motionLayout.requireViewById<View>(it) 329 cardView.backgroundTintList = ColorStateList.valueOf(color) 330 } 331 } 332 } 333 334 private fun overrideScreenPreviewWidth() { 335 val overrideWidth = 336 context.resources.getDimensionPixelSize( 337 R.dimen.screen_preview_width_for_2_pane_small_width 338 ) 339 itemViewIds.forEach { id -> 340 val itemView = motionLayout.requireViewById<FrameLayout>(id) 341 val itemViewLp = itemView.layoutParams 342 itemViewLp.width = overrideWidth 343 itemView.layoutParams = itemViewLp 344 345 getClockScaleViewId(id)?.let { 346 val scaleView = motionLayout.requireViewById<FixedWidthDisplayRatioFrameLayout>(it) 347 val scaleViewLp = scaleView.layoutParams 348 scaleViewLp.width = overrideWidth 349 scaleView.layoutParams = scaleViewLp 350 } 351 } 352 353 val previousConstaintSet = motionLayout.getConstraintSet(R.id.previous) 354 val startConstaintSet = motionLayout.getConstraintSet(R.id.start) 355 val nextConstaintSet = motionLayout.getConstraintSet(R.id.next) 356 val constaintSetList = 357 listOf<ConstraintSet>(previousConstaintSet, startConstaintSet, nextConstaintSet) 358 constaintSetList.forEach { constraintSet -> 359 itemViewIds.forEach { id -> 360 constraintSet.getConstraint(id)?.let { constraint -> 361 val layout = constraint.layout 362 if ( 363 constraint.layout.mWidth == 364 context.resources.getDimensionPixelSize(R.dimen.screen_preview_width) 365 ) { 366 layout.mWidth = overrideWidth 367 } 368 if ( 369 constraint.layout.widthMax == 370 context.resources.getDimensionPixelSize(R.dimen.screen_preview_width) 371 ) { 372 layout.widthMax = overrideWidth 373 } 374 } 375 } 376 } 377 } 378 379 private class ClockCarouselAdapter( 380 val clockSize: ClockSize, 381 val clocks: List<ClockCarouselItemViewModel>, 382 private val clockViewFactory: ClockViewFactory, 383 private val onClockSelected: (clock: ClockCarouselItemViewModel) -> Unit 384 ) : Carousel.Adapter { 385 386 fun getContentDescription(index: Int, resources: Resources): String { 387 return clocks[index].getContentDescription(resources) 388 } 389 390 override fun count(): Int { 391 return clocks.size 392 } 393 394 override fun populate(view: View?, index: Int) { 395 val viewRoot = view as? ViewGroup ?: return 396 val cardView = 397 getClockCardViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? View } 398 ?: return 399 val clockScaleView = 400 getClockScaleViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? View } 401 ?: return 402 val clockHostView = 403 getClockHostViewId(viewRoot.id)?.let { viewRoot.findViewById(it) as? ClockHostView } 404 ?: return 405 val clockId = clocks[index].clockId 406 407 // Add the clock view to the cloc host view 408 clockHostView.removeAllViews() 409 val clockView = 410 when (clockSize) { 411 ClockSize.DYNAMIC -> clockViewFactory.getLargeView(clockId) 412 ClockSize.SMALL -> clockViewFactory.getSmallView(clockId) 413 } 414 // The clock view might still be attached to an existing parent. Detach before adding to 415 // another parent. 416 (clockView.parent as? ViewGroup)?.removeView(clockView) 417 clockHostView.addView(clockView) 418 419 val isMiddleView = isMiddleView(viewRoot.id) 420 421 // Accessibility 422 viewRoot.contentDescription = getContentDescription(index, view.resources) 423 viewRoot.isSelected = isMiddleView 424 425 when (clockSize) { 426 ClockSize.DYNAMIC -> 427 initializeDynamicClockView( 428 isMiddleView, 429 clockScaleView, 430 clockId, 431 clockHostView, 432 ) 433 ClockSize.SMALL -> 434 initializeSmallClockView( 435 isMiddleView, 436 clockHostView, 437 clockView, 438 ) 439 } 440 cardView.alpha = if (isMiddleView) 0f else 1f 441 } 442 443 private fun initializeDynamicClockView( 444 isMiddleView: Boolean, 445 clockScaleView: View, 446 clockId: String, 447 clockHostView: ClockHostView, 448 ) { 449 clockHostView.doOnPreDraw { 450 it.pivotX = it.width / 2F 451 it.pivotY = it.height / 2F 452 } 453 if (isMiddleView) { 454 clockScaleView.scaleX = 1f 455 clockScaleView.scaleY = 1f 456 clockViewFactory 457 .getController(clockId) 458 .largeClock 459 .animations 460 .onPickerCarouselSwiping(1F) 461 } else { 462 clockScaleView.scaleX = CLOCK_CAROUSEL_VIEW_SCALE 463 clockScaleView.scaleY = CLOCK_CAROUSEL_VIEW_SCALE 464 clockViewFactory 465 .getController(clockId) 466 .largeClock 467 .animations 468 .onPickerCarouselSwiping(0F) 469 } 470 } 471 472 private fun initializeSmallClockView( 473 isMiddleView: Boolean, 474 clockHostView: ClockHostView, 475 clockView: View, 476 ) { 477 clockHostView.doOnPreDraw { 478 if (isMiddleView) { 479 it.pivotX = getCenteredHostViewPivotX(it) 480 it.pivotY = 0F 481 clockView.translationX = 0F 482 clockView.translationY = 0F 483 } else { 484 it.pivotX = it.width / 2F 485 it.pivotY = it.height / 2F 486 clockView.translationX = 487 getTranslationDistance( 488 clockHostView.width, 489 clockView.width, 490 clockView.left, 491 ) 492 clockView.translationY = 493 getTranslationDistance( 494 clockHostView.height, 495 clockView.height, 496 clockView.top, 497 ) 498 } 499 } 500 } 501 502 override fun onNewItem(index: Int) { 503 onClockSelected.invoke(clocks[index]) 504 } 505 } 506 507 companion object { 508 // The carousel needs to have at least 5 different clock faces to be infinite 509 const val MIN_CLOCKS_TO_ENABLE_INFINITE_CAROUSEL = 5 510 const val CLOCK_CAROUSEL_VIEW_SCALE = 0.5f 511 const val TRANSITION_DURATION = 250 512 513 val itemViewIds = 514 listOf( 515 R.id.item_view_0, 516 R.id.item_view_1, 517 R.id.item_view_2, 518 R.id.item_view_3, 519 R.id.item_view_4 520 ) 521 522 fun getScalingUpScale(progress: Float) = 523 CLOCK_CAROUSEL_VIEW_SCALE + progress * (1f - CLOCK_CAROUSEL_VIEW_SCALE) 524 525 fun getScalingDownScale(progress: Float) = 1f - progress * (1f - CLOCK_CAROUSEL_VIEW_SCALE) 526 527 // This makes the card only starts to reveal in the last quarter of the trip so 528 // the card won't overlap the preview. 529 fun getShowingAlpha(progress: Float) = max(progress - 0.75f, 0f) * 4 530 531 // This makes the card starts to hide in the first quarter of the trip so the 532 // card won't overlap the preview. 533 fun getHidingAlpha(progress: Float) = max(1f - progress * 4, 0f) 534 535 fun getClockHostViewId(rootViewId: Int): Int? { 536 return when (rootViewId) { 537 R.id.item_view_0 -> R.id.clock_host_view_0 538 R.id.item_view_1 -> R.id.clock_host_view_1 539 R.id.item_view_2 -> R.id.clock_host_view_2 540 R.id.item_view_3 -> R.id.clock_host_view_3 541 R.id.item_view_4 -> R.id.clock_host_view_4 542 else -> null 543 } 544 } 545 546 fun getClockScaleViewId(rootViewId: Int): Int? { 547 return when (rootViewId) { 548 R.id.item_view_0 -> R.id.clock_scale_view_0 549 R.id.item_view_1 -> R.id.clock_scale_view_1 550 R.id.item_view_2 -> R.id.clock_scale_view_2 551 R.id.item_view_3 -> R.id.clock_scale_view_3 552 R.id.item_view_4 -> R.id.clock_scale_view_4 553 else -> null 554 } 555 } 556 557 fun getClockCardViewId(rootViewId: Int): Int? { 558 return when (rootViewId) { 559 R.id.item_view_0 -> R.id.item_card_0 560 R.id.item_view_1 -> R.id.item_card_1 561 R.id.item_view_2 -> R.id.item_card_2 562 R.id.item_view_3 -> R.id.item_card_3 563 R.id.item_view_4 -> R.id.item_card_4 564 else -> null 565 } 566 } 567 568 fun isMiddleView(rootViewId: Int): Boolean { 569 return rootViewId == R.id.item_view_2 570 } 571 572 fun getCenteredHostViewPivotX(hostView: View): Float { 573 return if (hostView.isLayoutRtl) hostView.width.toFloat() else 0F 574 } 575 576 private fun getTranslationDistance( 577 hostLength: Int, 578 frameLength: Int, 579 edgeDimen: Int, 580 ): Float { 581 return ((hostLength - frameLength) / 2 - edgeDimen).toFloat() 582 } 583 } 584 } 585