Skip to content

Commit 85cba1d

Browse files
add GooeyProgressIndicator
1 parent 7f92ba8 commit 85cba1d

File tree

1 file changed

+286
-0
lines changed

1 file changed

+286
-0
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package com.smarttoolfactory.progressindicator
2+
3+
import android.graphics.DiscretePathEffect
4+
import androidx.compose.animation.core.*
5+
import androidx.compose.foundation.Canvas
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.runtime.*
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.geometry.Offset
10+
import androidx.compose.ui.geometry.Rect
11+
import androidx.compose.ui.graphics.*
12+
import androidx.compose.ui.graphics.drawscope.DrawScope
13+
import androidx.compose.ui.graphics.drawscope.Stroke
14+
import androidx.compose.ui.unit.dp
15+
import com.smarttoolfactory.progressindicator.IndicatorDefaults.DefaultGradientColors
16+
import kotlin.math.cos
17+
import kotlin.math.sin
18+
19+
private const val DEGREE_TO_RADIAN = Math.PI / 180
20+
21+
22+
// TODO Use bezier curves to to have shaky and sticky effect
23+
@Composable
24+
fun GooeyProgressIndicator(
25+
modifier: Modifier = Modifier,
26+
style:IndicatorStyle = IndicatorStyle.Filled
27+
) {
28+
29+
val staticCircleCount = 8
30+
val segmentCount = 20
31+
32+
val pathMeasure = remember {
33+
PathMeasure()
34+
}
35+
36+
val pathDynamic = remember { Path() }
37+
val pathStatic = remember { Path() }
38+
39+
val cornerPathEffect = remember {
40+
PathEffect.cornerPathEffect(50f)
41+
}
42+
43+
val target = 360f
44+
45+
val infiniteTransition = rememberInfiniteTransition()
46+
val angle by infiniteTransition.animateFloat(
47+
initialValue = 0f,
48+
targetValue = target,
49+
infiniteRepeatable(
50+
animation = keyframes {
51+
durationMillis = 2500
52+
target * 0.8f at 1300
53+
target * 0.1f at 150 with FastOutLinearInEasing
54+
target * 0.1f at 150
55+
}
56+
)
57+
)
58+
59+
if(style == IndicatorStyle.Filled){
60+
FilledGooeyIndicatorImpl(
61+
modifier = modifier,
62+
pathStatic = pathStatic,
63+
pathDynamic = pathDynamic,
64+
staticCircleCount = staticCircleCount,
65+
angle = angle,
66+
pathMeasure = pathMeasure,
67+
cornerPathEffect = cornerPathEffect,
68+
segmentCount = segmentCount
69+
)
70+
}else {
71+
StrokeGooeyImpl(
72+
modifier = modifier,
73+
pathStatic = pathStatic,
74+
pathDynamic = pathDynamic,
75+
staticCircleCount = staticCircleCount,
76+
angle = angle,
77+
pathMeasure = pathMeasure,
78+
cornerPathEffect = cornerPathEffect,
79+
segmentCount = segmentCount
80+
)
81+
}
82+
}
83+
84+
@Composable
85+
private fun FilledGooeyIndicatorImpl(
86+
modifier: Modifier,
87+
pathStatic: Path,
88+
pathDynamic: Path,
89+
staticCircleCount: Int,
90+
angle: Float,
91+
pathMeasure: PathMeasure,
92+
cornerPathEffect: PathEffect,
93+
segmentCount: Int
94+
) {
95+
96+
val paint = remember {
97+
Paint().apply {
98+
color = Color.Red.copy(alpha = .7f)
99+
}
100+
}
101+
102+
var isPaintSetUp by remember {
103+
mutableStateOf(false)
104+
}
105+
106+
107+
Canvas(modifier.size(48.dp)) {
108+
109+
var canvasWidth = size.width
110+
var canvasHeight = size.height
111+
112+
if (canvasHeight < canvasWidth) {
113+
canvasWidth = canvasHeight
114+
} else {
115+
canvasHeight = canvasWidth
116+
}
117+
118+
val width = canvasWidth
119+
val height = canvasHeight
120+
121+
val dynamicCircleRadius = width.coerceAtMost(height) * .15f
122+
123+
// This is distance of center of circles to center of progress indicator
124+
val radiusDynamicContainer = (width / 2 - dynamicCircleRadius)
125+
126+
if (pathStatic.isEmpty) {
127+
128+
val circleRadius = width.coerceAtMost(height) * .1f
129+
130+
for (i in 0..360 step 360 / staticCircleCount) {
131+
val rect = Rect(
132+
center = Offset(
133+
x = (center.x + radiusDynamicContainer * cos(i * DEGREE_TO_RADIAN)).toFloat(),
134+
y = (center.y + radiusDynamicContainer * sin(i * DEGREE_TO_RADIAN)).toFloat()
135+
),
136+
radius = circleRadius
137+
)
138+
139+
pathStatic.addOval(rect)
140+
}
141+
}
142+
143+
val dynamicCircleCenter = Offset(
144+
x = (center.x + radiusDynamicContainer * cos(angle * DEGREE_TO_RADIAN)).toFloat(),
145+
y = (center.y + radiusDynamicContainer * sin(angle * DEGREE_TO_RADIAN)).toFloat()
146+
)
147+
148+
val rect = Rect(
149+
center = dynamicCircleCenter,
150+
radius = dynamicCircleRadius
151+
)
152+
153+
pathDynamic.reset()
154+
pathDynamic.addOval(rect)
155+
156+
pathMeasure.setPath(pathDynamic, true)
157+
val discretePathEffect = DiscretePathEffect(pathMeasure.length / segmentCount, 0f)
158+
159+
val chainPathEffect = PathEffect.chainPathEffect(
160+
outer = cornerPathEffect,
161+
inner = discretePathEffect.toComposePathEffect()
162+
)
163+
164+
pathDynamic.op(pathDynamic, pathStatic, PathOperation.Union)
165+
166+
drawFilledGooeyIndicators(
167+
paint,
168+
chainPathEffect,
169+
isPaintSetUp = isPaintSetUp,
170+
colors = DefaultGradientColors,
171+
path = pathDynamic
172+
){
173+
isPaintSetUp = true
174+
}
175+
}
176+
}
177+
178+
@Composable
179+
private fun StrokeGooeyImpl(
180+
modifier: Modifier,
181+
pathStatic: Path,
182+
pathDynamic: Path,
183+
staticCircleCount: Int,
184+
angle: Float,
185+
pathMeasure: PathMeasure,
186+
cornerPathEffect: PathEffect,
187+
segmentCount: Int
188+
) {
189+
190+
Canvas(modifier.size(48.dp)) {
191+
192+
var canvasWidth = size.width
193+
var canvasHeight = size.height
194+
195+
if (canvasHeight < canvasWidth) {
196+
canvasWidth = canvasHeight
197+
} else {
198+
canvasHeight = canvasWidth
199+
}
200+
201+
val width = canvasWidth
202+
val height = canvasHeight
203+
204+
val dynamicCircleRadius = width.coerceAtMost(height) * .15f
205+
206+
// This is distance of center of circles to center of progress indicator
207+
val radiusDynamicContainer = (width / 2 - dynamicCircleRadius)
208+
209+
if (pathStatic.isEmpty) {
210+
211+
val circleRadius = width.coerceAtMost(height) * .1f
212+
213+
for (i in 0..360 step 360 / staticCircleCount) {
214+
val rect = Rect(
215+
center = Offset(
216+
x = (center.x + radiusDynamicContainer * cos(i * DEGREE_TO_RADIAN)).toFloat(),
217+
y = (center.y + radiusDynamicContainer * sin(i * DEGREE_TO_RADIAN)).toFloat()
218+
),
219+
radius = circleRadius
220+
)
221+
222+
pathStatic.addOval(rect)
223+
}
224+
}
225+
226+
val dynamicCircleCenter = Offset(
227+
x = (center.x + radiusDynamicContainer * cos(angle * DEGREE_TO_RADIAN)).toFloat(),
228+
y = (center.y + radiusDynamicContainer * sin(angle * DEGREE_TO_RADIAN)).toFloat()
229+
)
230+
231+
val rect = Rect(
232+
center = dynamicCircleCenter,
233+
radius = dynamicCircleRadius
234+
)
235+
236+
pathDynamic.reset()
237+
pathDynamic.addOval(rect)
238+
239+
pathMeasure.setPath(pathDynamic, true)
240+
val discretePathEffect = DiscretePathEffect(pathMeasure.length / segmentCount, 0f)
241+
242+
val chainPathEffect = PathEffect.chainPathEffect(
243+
outer = cornerPathEffect,
244+
inner = discretePathEffect.toComposePathEffect()
245+
)
246+
247+
pathDynamic.op(pathDynamic, pathStatic, PathOperation.Union)
248+
249+
drawPath(
250+
path = pathDynamic,
251+
color = Color.Red,
252+
style = Stroke(1.dp.toPx(), pathEffect = chainPathEffect)
253+
)
254+
}
255+
}
256+
257+
258+
private fun DrawScope.drawFilledGooeyIndicators(
259+
paint: Paint,
260+
chainPathEffect: PathEffect,
261+
isPaintSetUp: Boolean,
262+
colors: List<Color> = DefaultGradientColors,
263+
path: Path,
264+
onPaintSetUp: () -> Unit
265+
) {
266+
paint.pathEffect = chainPathEffect
267+
268+
if (!isPaintSetUp) {
269+
paint.shader = LinearGradientShader(
270+
from = Offset.Zero,
271+
to = Offset(size.width, size.height),
272+
colors = colors,
273+
tileMode = TileMode.Clamp
274+
)
275+
paint.pathEffect = chainPathEffect
276+
onPaintSetUp()
277+
}
278+
279+
280+
with(drawContext.canvas) {
281+
this.drawPath(
282+
path,
283+
paint
284+
)
285+
}
286+
}

0 commit comments

Comments
 (0)