• 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.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