1 /*
<lambda>null2 * Copyright (C) 2024 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
17 package com.android.systemui.common.ui.compose
18
19 import androidx.compose.animation.core.animateDpAsState
20 import androidx.compose.foundation.Canvas
21 import androidx.compose.foundation.layout.Arrangement.spacedBy
22 import androidx.compose.foundation.layout.Row
23 import androidx.compose.foundation.layout.size
24 import androidx.compose.foundation.layout.wrapContentWidth
25 import androidx.compose.foundation.pager.PagerState
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.derivedStateOf
28 import androidx.compose.runtime.getValue
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.rememberCoroutineScope
31 import androidx.compose.ui.Alignment
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.geometry.CornerRadius
34 import androidx.compose.ui.geometry.Offset
35 import androidx.compose.ui.geometry.Size
36 import androidx.compose.ui.graphics.Color
37 import androidx.compose.ui.graphics.drawscope.DrawScope
38 import androidx.compose.ui.graphics.drawscope.scale
39 import androidx.compose.ui.semantics.pageLeft
40 import androidx.compose.ui.semantics.pageRight
41 import androidx.compose.ui.semantics.semantics
42 import androidx.compose.ui.semantics.stateDescription
43 import androidx.compose.ui.unit.Dp
44 import androidx.compose.ui.unit.LayoutDirection
45 import androidx.compose.ui.unit.dp
46 import com.android.app.tracing.coroutines.launchTraced as launch
47 import kotlin.math.absoluteValue
48 import kotlinx.coroutines.CoroutineScope
49 import platform.test.motion.compose.values.MotionTestValueKey
50 import platform.test.motion.compose.values.motionTestValues
51
52 @Composable
53 fun PagerDots(
54 pagerState: PagerState,
55 activeColor: Color,
56 nonActiveColor: Color,
57 modifier: Modifier = Modifier,
58 dotSize: Dp = 6.dp,
59 spaceSize: Dp = 4.dp,
60 ) {
61 if (pagerState.pageCount < 2) {
62 return
63 }
64 val inPageTransition by
65 remember(pagerState) {
66 derivedStateOf {
67 pagerState.currentPageOffsetFraction.absoluteValue > 0.05 &&
68 !pagerState.isOverscrolling()
69 }
70 }
71 val coroutineScope = rememberCoroutineScope()
72 val doubleDotWidth = dotSize * 2 + spaceSize
73 val activeMarkerWidth by
74 animateDpAsState(
75 targetValue = if (inPageTransition) doubleDotWidth else dotSize,
76 label = "PagerDotsTransitionAnimation",
77 )
78 val cornerRadius = dotSize / 2
79
80 fun DrawScope.drawDoubleRect(withPrevious: Boolean, width: Dp) {
81 drawRoundRect(
82 topLeft =
83 Offset(
84 if (withPrevious) {
85 dotSize.toPx() - width.toPx()
86 } else {
87 -(dotSize.toPx() + spaceSize.toPx())
88 },
89 0f,
90 ),
91 color = activeColor,
92 size = Size(width.toPx(), dotSize.toPx()),
93 cornerRadius = CornerRadius(cornerRadius.toPx()),
94 )
95 }
96
97 Row(
98 modifier =
99 modifier
100 .motionTestValues { activeMarkerWidth exportAs PagerDotsMotionKeys.indicatorWidth }
101 .wrapContentWidth()
102 .pagerDotsSemantics(pagerState, coroutineScope),
103 horizontalArrangement = spacedBy(spaceSize),
104 verticalAlignment = Alignment.CenterVertically,
105 ) {
106 // This means that the active rounded rect has to be drawn between the current page
107 // and the previous one (as we are animating back), or the current one if not transitioning
108 val withPrevious by
109 remember(pagerState) {
110 derivedStateOf {
111 pagerState.currentPageOffsetFraction <= 0 || pagerState.isOverscrolling()
112 }
113 }
114 repeat(pagerState.pageCount) { page ->
115 Canvas(Modifier.size(dotSize)) {
116 val rtl = layoutDirection == LayoutDirection.Rtl
117 scale(if (rtl) -1f else 1f, 1f, Offset(0f, center.y)) {
118 drawCircle(nonActiveColor)
119 // We always want to draw the rounded rect on the rightmost dot iteration, so
120 // the inactive dot is always drawn behind.
121 // This means that:
122 // * if we are scrolling back, we draw it when we are in the current page (so it
123 // extends between this page and the previous one).
124 // * if we are scrolling forward, we draw it when we are in the next page (so it
125 // extends between the next page and the current one).
126 // * if we are not scrolling, withPrevious is true (pageOffset 0) and we
127 // draw in the current page.
128 // drawDoubleRect calculates the offset based on the above.
129 if (
130 withPrevious && page == pagerState.currentPage ||
131 (!withPrevious && page == pagerState.currentPage + 1)
132 ) {
133 drawDoubleRect(withPrevious, activeMarkerWidth)
134 }
135 }
136 }
137 }
138 }
139 }
140
141 object PagerDotsMotionKeys {
142 val indicatorWidth = MotionTestValueKey<Dp>("indicatorWidth")
143 }
144
pagerDotsSemanticsnull145 private fun Modifier.pagerDotsSemantics(
146 pagerState: PagerState,
147 coroutineScope: CoroutineScope,
148 ): Modifier {
149 return then(
150 Modifier.semantics {
151 pageLeft {
152 if (pagerState.canScrollBackward) {
153 coroutineScope.launch {
154 pagerState.animateScrollToPage(pagerState.currentPage - 1)
155 }
156 true
157 } else {
158 false
159 }
160 }
161 pageRight {
162 if (pagerState.canScrollForward) {
163 coroutineScope.launch {
164 pagerState.animateScrollToPage(pagerState.currentPage + 1)
165 }
166 true
167 } else {
168 false
169 }
170 }
171 stateDescription = "Page ${pagerState.settledPage + 1} of ${pagerState.pageCount}"
172 }
173 )
174 }
175
isOverscrollingnull176 private fun PagerState.isOverscrolling(): Boolean {
177 val position = currentPage + currentPageOffsetFraction
178 return position < 0 || position > pageCount - 1
179 }
180