-
-
Save atsushieno/90551a7a4d6dd411e00ba4bfdb59eac6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package dev.atsushieno.composeaudiocontrols | |
import android.util.TypedValue | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.gestures.Orientation | |
import androidx.compose.foundation.gestures.draggable | |
import androidx.compose.foundation.gestures.rememberDraggableState | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.offset | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.ImageBitmap | |
import androidx.compose.ui.graphics.drawscope.DrawScope | |
import androidx.compose.ui.graphics.painter.Painter | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.imageResource | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.IntOffset | |
import androidx.compose.ui.unit.IntSize | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.compose.ui.unit.toSize | |
import kotlin.math.min | |
import kotlin.math.roundToInt | |
private val defaultKnobMinSizeInDp = 50.dp | |
@Composable | |
fun Knob(modifier: Modifier = Modifier, | |
drawableResId: Int, | |
value: Float = 0f, | |
minValue: Float = 0f, | |
maxValue: Float = 1f, // typical float value range: 0.0 - 1.0 | |
minSizeInDp: Dp = defaultKnobMinSizeInDp, | |
tooltipColor: Color = Color.Gray, | |
tooltip: @Composable (value: Float, isBeingDragged: Boolean) -> Unit = { v, isBeingDragged -> | |
// by default, show tooltip only when it is being dragged | |
DefaultKnobTooltip( | |
value = v, | |
showTooltip = isBeingDragged, | |
textColor = tooltipColor | |
) | |
}, | |
valueChanged: (value: Float) -> Unit = {} | |
) { | |
val context = LocalContext.current | |
val res = context.resources | |
val resValue = remember { TypedValue() } | |
res.getValue(drawableResId, resValue, true) | |
val path = resValue.string | |
val imageBitmap = remember(path, drawableResId, context.theme) { | |
ImageBitmap.imageResource(res, drawableResId) | |
} | |
Knob( | |
modifier, | |
imageBitmap, | |
value, | |
minValue, | |
maxValue, | |
minSizeInDp, | |
tooltipColor, | |
tooltip, | |
valueChanged | |
) | |
} | |
@Composable | |
fun Knob(modifier: Modifier = Modifier, | |
imageBitmap: ImageBitmap, | |
value: Float = 0f, | |
minValue: Float = 0f, | |
maxValue: Float = 1f, | |
minSizeInDp: Dp = defaultKnobMinSizeInDp, | |
tooltipColor: Color = Color.Gray, | |
tooltip: @Composable (value: Float, isBeingDragged: Boolean) -> Unit = { value, isBeingDragged -> | |
// by default, show tooltip only when it is being dragged | |
DefaultKnobTooltip( | |
value = value, | |
showTooltip = isBeingDragged, | |
textColor = tooltipColor | |
) | |
}, | |
valueChanged: (value: Float) -> Unit = {} | |
) { | |
// assuming these properties are cosmetic to acquire and thus can be computed every time... | |
val knobSrcSizePx = imageBitmap.width | |
val numKnobSlices = imageBitmap.height / imageBitmap.width | |
val valueDelta = (maxValue - minValue) / numKnobSlices | |
val normalizedValue = if (value > maxValue) maxValue else if (value < minValue) minValue else value | |
val imageIndex = min(numKnobSlices - 1, (normalizedValue / valueDelta).toInt()) | |
with(LocalDensity.current) { | |
var isBeingDragged by remember { mutableStateOf(false) } | |
val sizePx = if (minSizeInDp.toPx() > knobSrcSizePx) minSizeInDp.toPx() else knobSrcSizePx.toFloat() | |
val draggableState = rememberDraggableState(onDelta = { | |
val v = value - it * 0.01f | |
val next = if (v < minValue) minValue else if (maxValue < v) maxValue else v | |
isBeingDragged = true | |
if (value != next) | |
valueChanged(next) | |
}) | |
Column { | |
Image( | |
ScalingPainter( | |
imageBitmap, | |
srcSize = IntSize(knobSrcSizePx, knobSrcSizePx), | |
srcOffset = IntOffset(0, knobSrcSizePx * imageIndex), | |
scale = sizePx / knobSrcSizePx | |
), | |
contentDescription = "knob image", | |
contentScale = ContentScale.Inside, | |
alignment = Alignment.TopStart, | |
modifier = modifier | |
// our settings take higher priority | |
.draggable(draggableState, Orientation.Vertical, | |
onDragStopped = { isBeingDragged = false }) | |
.size(sizePx.toDp()) | |
) | |
tooltip(value, isBeingDragged) | |
} | |
} | |
} | |
@Composable | |
fun DefaultKnobTooltip(modifier: Modifier = Modifier, showTooltip: Boolean, value: Float, textColor: Color = Color.Gray) { | |
if (showTooltip) | |
Text( | |
value.toString().take(if (value < 0) 6 else 5), | |
fontSize = 12.sp, | |
color = textColor, | |
textAlign = TextAlign.Center, | |
modifier = Modifier.offset(8.dp, 0.dp).then(modifier) | |
) | |
else | |
with(LocalDensity.current) { | |
Box(Modifier.height(16.sp.toDp())) | |
} | |
} | |
class ScalingPainter(private val image: ImageBitmap, | |
private val srcOffset: IntOffset = IntOffset.Zero, | |
private val srcSize: IntSize = IntSize(image.width, image.height), | |
scale: Float = 1f | |
) : Painter() { | |
private val validatedSize = validateSize(srcOffset, srcSize) | |
private val width = validatedSize.width * scale | |
private val height = validatedSize.height * scale | |
override val intrinsicSize: Size | |
get() = validatedSize.toSize() | |
override fun DrawScope.onDraw() { | |
drawImage( | |
image, | |
srcOffset, | |
srcSize, | |
dstSize = IntSize(width.roundToInt(), height.roundToInt()) | |
) | |
} | |
// idea and code taken from CustomPainterSnippets.kt in android/snippets repo | |
private fun validateSize(srcOffset: IntOffset, srcSize: IntSize): IntSize { | |
require( | |
srcOffset.x >= 0 && | |
srcOffset.y >= 0 && | |
srcSize.width >= 0 && | |
srcSize.height >= 0 && | |
srcSize.width <= image.width && | |
srcSize.height <= image.height | |
) | |
return srcSize | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package dev.atsushieno.composeaudiocontrols | |
import android.os.Bundle | |
import androidx.activity.ComponentActivity | |
import androidx.activity.compose.setContent | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Surface | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import dev.atsushieno.composeaudiocontrols.ui.theme.ComposeAudioControlsTheme | |
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContent { | |
ComposeAudioControlsTheme { | |
// A surface container using the 'background' color from the theme | |
Surface( | |
modifier = Modifier.fillMaxSize(), | |
color = MaterialTheme.colorScheme.background | |
) { | |
KnobDemo() | |
} | |
} | |
} | |
} | |
} | |
@Preview(showBackground = true) | |
@Composable | |
fun KnobPreview() { | |
ComposeAudioControlsTheme { | |
KnobDemo() | |
} | |
} | |
@Composable | |
fun KnobDemo() { | |
Column { | |
(0 until 10).forEach { paramIndex -> | |
Row { | |
var paramValue by remember { mutableStateOf(0f) } | |
Text("Parameter $paramIndex: ") | |
Knob(drawableResId = R.drawable.bright_life, | |
value = paramValue, | |
//tooltip = { _,_ -> }, | |
valueChanged = {v -> | |
paramValue = v | |
println("value at $paramIndex changed: $v") | |
}) | |
Text(paramValue.toString().take(if (paramValue < 0) 8 else 7)) | |
TextButton(onClick = { paramValue /= 2f} ) { | |
Text("divide by 2", modifier = Modifier.border(1.dp, Color.Black)) | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment