Skip to content

Commit 4319abc

Browse files
committed
Boolti-235 feat: 인스타그램 인디케이터 작성 (보수 필요)
1 parent df24f33 commit 4319abc

File tree

1 file changed

+231
-0
lines changed

1 file changed

+231
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package com.nexters.boolti.presentation.component
2+
3+
import android.animation.ArgbEvaluator
4+
import androidx.compose.animation.core.animateFloatAsState
5+
import androidx.compose.animation.core.spring
6+
import androidx.compose.foundation.Canvas
7+
import androidx.compose.foundation.ExperimentalFoundationApi
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Column
10+
import androidx.compose.foundation.layout.PaddingValues
11+
import androidx.compose.foundation.layout.fillMaxSize
12+
import androidx.compose.foundation.layout.height
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.layout.size
15+
import androidx.compose.foundation.pager.HorizontalPager
16+
import androidx.compose.foundation.pager.PagerState
17+
import androidx.compose.foundation.pager.rememberPagerState
18+
import androidx.compose.material3.Card
19+
import androidx.compose.material3.Surface
20+
import androidx.compose.material3.Text
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.LaunchedEffect
23+
import androidx.compose.runtime.derivedStateOf
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.runtime.snapshotFlow
29+
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.geometry.Offset
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.graphics.Color.Companion.White
34+
import androidx.compose.ui.graphics.toArgb
35+
import androidx.compose.ui.tooling.preview.Preview
36+
import androidx.compose.ui.unit.Dp
37+
import androidx.compose.ui.unit.dp
38+
import com.nexters.boolti.presentation.theme.BooltiTheme
39+
import kotlin.math.absoluteValue
40+
41+
private data class IndicatorRange(
42+
@androidx.annotation.IntRange(from = 0) val start: Int,
43+
@androidx.annotation.IntRange(from = 0) val end: Int,
44+
) {
45+
init {
46+
check(start <= end)
47+
}
48+
49+
val size: Int
50+
get() = (end - start).absoluteValue + 1
51+
52+
operator fun invoke(): IntRange = start..end
53+
operator fun minus(amount: Int): IndicatorRange = copy(start = start - amount, end = end - amount)
54+
operator fun plus(amount: Int): IndicatorRange = copy(start = start + amount, end = end + amount)
55+
}
56+
57+
private class IndicatorUtil(
58+
val dotCount: Int = 5,
59+
val dotSize: Dp = 7.dp,
60+
val mediumDotSize: Dp = 5.dp,
61+
val smallDotSize: Dp = 4.dp,
62+
val spacedBy: Dp = 8.dp,
63+
val activeColor: Color = White,
64+
val inactiveColor: Color = White.copy(alpha = 0.5f),
65+
) {
66+
private val evaluator = ArgbEvaluator()
67+
68+
fun calculateWidth(pageCount: Int): Dp {
69+
if (pageCount == 0) return 0.dp
70+
71+
val visibleCount = minOf(pageCount, dotCount) + 4
72+
return dotSize * visibleCount + spacedBy * (visibleCount - 1)
73+
}
74+
75+
fun calculateHeight(): Dp = maxOf(dotSize, mediumDotSize, smallDotSize)
76+
77+
fun calculateDotSize(
78+
index: Int,
79+
range: IndicatorRange,
80+
offsetFraction: Float,
81+
): Dp {
82+
if (index in range()) return dotSize / 2
83+
84+
val diff = when (index < range.start) {
85+
true -> offsetFraction - index
86+
false -> index - (offsetFraction + dotCount - 1)
87+
}
88+
89+
return when {
90+
diff < 1f -> (dotSize + (mediumDotSize - dotSize) * diff) / 2
91+
diff < 2f -> (mediumDotSize + (smallDotSize - mediumDotSize) * (diff - 1f)) / 2
92+
diff < 3f -> (smallDotSize - mediumDotSize * (diff - 2f)) / 2
93+
else -> 0.dp
94+
}
95+
}
96+
97+
fun calculateDotColor(index: Int, pageCount: Int, pageFraction: Float): Color = when {
98+
index in 0 until pageCount -> {
99+
val fraction = 1 - (index - pageFraction).absoluteValue.coerceAtMost(1f)
100+
Color(evaluator.evaluate(fraction, inactiveColor.toArgb(), activeColor.toArgb()) as Int)
101+
}
102+
103+
else -> Color.Transparent
104+
}
105+
106+
fun calculateX(
107+
index: Int,
108+
offsetFraction: Float,
109+
): Dp = dotSize / 2 + (dotSize + spacedBy) * index +
110+
(dotSize + spacedBy) * 2 -
111+
(dotSize + spacedBy) * offsetFraction
112+
}
113+
114+
@OptIn(ExperimentalFoundationApi::class)
115+
@Composable
116+
fun InstagramIndicator(
117+
pagerState: PagerState,
118+
modifier: Modifier = Modifier,
119+
dotCount: Int = 5,
120+
dotSize: Dp = 7.dp,
121+
spacedBy: Dp = 8.dp,
122+
activeColor: Color = White,
123+
inactiveColor: Color = White.copy(alpha = 0.5f),
124+
) {
125+
val indicatorUtil = remember {
126+
IndicatorUtil(
127+
dotSize = dotSize,
128+
mediumDotSize = dotSize * 0.714f,
129+
smallDotSize = dotSize * 0.571f,
130+
spacedBy = spacedBy,
131+
activeColor = activeColor,
132+
inactiveColor = inactiveColor,
133+
)
134+
}
135+
136+
var range by remember {
137+
mutableStateOf(IndicatorRange(0, dotCount - 1))
138+
}
139+
140+
val pageFraction by remember {
141+
derivedStateOf {
142+
pagerState.currentPage + pagerState.currentPageOffsetFraction
143+
}
144+
}
145+
146+
val visibleRange by remember {
147+
derivedStateOf {
148+
IndicatorRange(range.start - 2, range.end + 2)
149+
}
150+
}
151+
152+
val offsetFraction by animateFloatAsState(
153+
targetValue = range.start.toFloat(),
154+
animationSpec = spring(),
155+
label = "offsetFraction",
156+
)
157+
158+
LaunchedEffect(pagerState) {
159+
snapshotFlow { pagerState.currentPage }.collect { page ->
160+
range = when {
161+
page > range.end -> range + 1
162+
page < range.start -> range - 1
163+
else -> range
164+
}
165+
}
166+
}
167+
168+
val indicatorWidth = remember(pagerState.pageCount) {
169+
indicatorUtil.calculateWidth(pagerState.pageCount)
170+
}
171+
172+
val indicatorHeight = remember(indicatorUtil) {
173+
indicatorUtil.calculateHeight()
174+
}
175+
176+
Canvas(
177+
modifier = modifier
178+
.size(width = indicatorWidth, height = indicatorHeight),
179+
) {
180+
repeat(visibleRange.size) { i ->
181+
val index = visibleRange.start + i
182+
drawCircle(
183+
color = indicatorUtil.calculateDotColor(index, pagerState.pageCount, pageFraction),
184+
radius = indicatorUtil.calculateDotSize(index, range, offsetFraction).toPx(),
185+
center = Offset(
186+
x = indicatorUtil.calculateX(index, offsetFraction).toPx(),
187+
y = center.y,
188+
),
189+
)
190+
}
191+
}
192+
}
193+
194+
@OptIn(ExperimentalFoundationApi::class)
195+
@Preview
196+
@Composable
197+
private fun InstagramIndicatorPreview() {
198+
BooltiTheme {
199+
Surface {
200+
val pagerState = rememberPagerState { 20 }
201+
202+
Column(
203+
modifier = Modifier.padding(vertical = 32.dp),
204+
horizontalAlignment = Alignment.CenterHorizontally,
205+
) {
206+
HorizontalPager(
207+
modifier = Modifier
208+
.height(400.dp),
209+
state = pagerState,
210+
contentPadding = PaddingValues(horizontal = 24.dp),
211+
pageSpacing = 16.dp,
212+
) { page ->
213+
Card(
214+
modifier = Modifier.fillMaxSize(),
215+
) {
216+
Box(
217+
modifier = Modifier.fillMaxSize(),
218+
contentAlignment = Alignment.Center,
219+
) {
220+
Text("page: ${page + 1}")
221+
}
222+
}
223+
}
224+
InstagramIndicator(
225+
pagerState = pagerState,
226+
modifier = Modifier.padding(top = 16.dp)
227+
)
228+
}
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)