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