Skip to content

Instantly share code, notes, and snippets.

@emeowj
Last active December 5, 2024 06:36
Show Gist options
  • Select an option

  • Save emeowj/7474ab49a0a77a9493a542eabb67812d to your computer and use it in GitHub Desktop.

Select an option

Save emeowj/7474ab49a0a77a9493a542eabb67812d to your computer and use it in GitHub Desktop.
DialControl Step 2
@Composable
fun <T> DialControlBox(
options: List<T>,
optionContent: @Composable (T, Boolean) -> Unit,
onSelected: (T) -> Unit,
modifier: Modifier = Modifier, config: DialConfig = DialConfig()
) {
val coroutineScope = rememberCoroutineScope()
var visible by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(Offset.Zero) }
val indicatorOffset = remember {
Animatable(
initialValue = Offset.Zero,
typeConverter = Offset.VectorConverter
)
}
val density = LocalDensity.current
val selectedOption: T? by remember {
derivedStateOf {
val sizePx = with(density) { config.dialSize.toPx() }
val radius = sizePx / 2
val currentOffset = indicatorOffset.value
val distance = currentOffset.getDistance()
if (distance < radius * config.cutOffFraction) {
null
} else {
val degree = (180f / Math.PI) * atan2(y = currentOffset.y, x = currentOffset.x)
val startAngle = calculateStartAngle(options.size)
val sweep = 360f / options.size
val index = options.indices.firstOrNull { index ->
val start = startAngle + sweep * index
val endAngle = start + sweep
degree >= startAngle && degree < endAngle
} ?: options.lastIndex
options[index]
}
}
}
val sectionScales = remember {
options.associateWith {
Animatable(
initialValue = 0f,
typeConverter = Float.VectorConverter
)
}
}
LaunchedEffect(selectedOption) {
sectionScales.forEach { (option, scale) ->
launch {
scale.animateTo(
targetValue = if (option == selectedOption) 1f else 0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
)
}
}
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
visible = true
offset = down.position
var change = awaitDragOrCancellation(pointerId = down.id)
while (change != null && change.pressed) {
val delta = change.positionChange()
coroutineScope.launch {
indicatorOffset.snapTo(indicatorOffset.value + delta)
}
change = awaitDragOrCancellation(pointerId = change.id)
}
visible = false
selectedOption?.let(onSelected)
coroutineScope.launch {
indicatorOffset.animateTo(Offset.Zero)
}
}
}
) {
AnimatedVisibility(
visible = visible,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier.graphicsLayer {
(offset - size.center).let {
translationX = it.x
translationY = it.y
}
}
) {
val dialColor: Color = MaterialTheme.colorScheme.surfaceContainer
DialControl(
options = options,
optionContent = optionContent,
selectedOption = selectedOption,
sectionScale = {
sectionScales[it]?.value ?: 0f
},
config = config,
dialColor = dialColor
) {
Box(
modifier = Modifier
.offset {
indicatorOffset.value.round()
}
.size(config.indicatorSize)
.background(color = dialColor, shape = CircleShape)
)
}
}
}
}
data class DialConfig(
val dialSize: Dp = 240.dp,
val indicatorSize: Dp = 32.dp,
@FloatRange(from = 0.0, to = 1.0) val cutOffFraction: Float = 0.4f,
)
@Composable
private fun <T> DialControl(
options: List<T>,
optionContent: @Composable (T, Boolean) -> Unit,
selectedOption: T?,
config: DialConfig,
sectionScale: (T) -> Float,
modifier: Modifier = Modifier,
dialColor: Color = MaterialTheme.colorScheme.surfaceContainer,
selectedColor: Color = MaterialTheme.colorScheme.primary,
indicator: @Composable () -> Unit,
) {
Box(modifier = modifier.size(config.dialSize), contentAlignment = Alignment.Center) {
DialBackground(
color = dialColor,
selectedColor = selectedColor,
cutOffFraction = config.cutOffFraction,
sectionCount = options.size,
sectionScale = { index ->
sectionScale(options[index])
}
)
DialContent(
options = options,
optionContent = optionContent,
selectedOption = selectedOption,
cutOffFraction = config.cutOffFraction,
dialSize = config.dialSize
)
indicator()
}
}
@Composable
private fun <T> DialContent(
options: List<T>,
optionContent: @Composable (T, Boolean) -> Unit,
selectedOption: T?,
cutOffFraction: Float,
dialSize: Dp
) {
val startDegree = calculateStartAngle(options.size)
val sweep = 360f / options.size
options.forEachIndexed { index, option ->
Box(
modifier = Modifier.graphicsLayer {
val angle = startDegree + sweep * index
val radians = (angle + sweep / 2) * Math.PI / 180
val radius =
(dialSize.toPx() / 2) * (cutOffFraction + (1f - cutOffFraction) / 2)
translationX = (radius * cos(radians)).toFloat()
translationY = (radius * sin(radians)).toFloat()
}
) {
optionContent(option, option == selectedOption)
}
}
}
@Composable
private fun DialBackground(
color: Color,
selectedColor: Color,
cutOffFraction: Float,
sectionCount: Int,
modifier: Modifier = Modifier,
sectionScale: (Int) -> Float,
) {
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
) {
drawCircle(color = color)
val startDegree = calculateStartAngle(sectionCount)
val sweep = 360f / sectionCount
var i = 0
while (i < sectionCount) {
rotate(startDegree + sweep * i) {
scale(sectionScale(i)) {
drawArc(
color = selectedColor,
startAngle = 0f,
sweepAngle = sweep,
useCenter = true
)
}
drawLine(
color = Color.Black,
start = center,
end = Offset(x = size.width, y = size.height / 2),
strokeWidth = 6.dp.toPx(),
blendMode = BlendMode.Clear
)
}
i++
}
scale(cutOffFraction) {
drawCircle(color = Color.Black, blendMode = BlendMode.Clear)
}
}
}
private fun calculateStartAngle(sectionCount: Int): Float {
val sweep = 360f / sectionCount
return -90f - sweep / 2
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment