• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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