Skip to content

Instantly share code, notes, and snippets.

@atsushieno
Last active May 31, 2023 10:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save atsushieno/90551a7a4d6dd411e00ba4bfdb59eac6 to your computer and use it in GitHub Desktop.
Save atsushieno/90551a7a4d6dd411e00ba4bfdb59eac6 to your computer and use it in GitHub Desktop.
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
}
}
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