All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.org.androidaudioplugin.composeaudiocontrols.ImageStripKnob.kt Maven / Gradle / Ivy

package org.androidaudioplugin.composeaudiocontrols

import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.awaitFirstDown
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.LayoutScopeMarker
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.Immutable
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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
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 kotlinx.datetime.Clock
import kotlin.math.min
import kotlin.math.roundToInt

// "Consider making touch targets at least 48x48dp, separated by 8dp of space or more, to ensure balanced information density and usability. "
//  https://support.google.com/accessibility/android/answer/7101858?hl=en
val defaultKnobMinSizeInDp = 48.dp

internal fun formatLabelNumber(v: Float, charsInPositiveNumber: Int = 5) = v.toDouble().toString().take(charsInPositiveNumber + if (v < 0) 1 else 0)

/**
 * Implements a knob control that is based on KnobMan image strip.
 * If you are not familiar with KnobMan, see [this KVR page](https://www.kvraudio.com/product/knobman-by-g200kg).
 *
 * ### Using ImageStripKnob
 *
 * Start dragging vertically from the knob to change the value within the range between `minValue` and `maxValue` arguments.
 * You only need one finger. No pinch required.
 *
 * If you hold over the knob for 1000 milliseconds (by default), it will enter "fine mode" where the value change is multiplied by 0.1.
 *
 * Value labels are rendered when it is dragged below the knob, by default.
 *
 * The knob is designed to minimize the space but also usable with human fingertips, by default (48.dp `minSizeInDp`).
 *
 * We currently do not assign anything for horizontal dragging by intention.
 * It is preserved for users who want to move out their finger of the knob and tooltip label.
 * Thus it is recommended to NOT assign another single-fingered drag operation over the knob.
 *
 * The whole min-to-max value change height on screen is `160.dp` which would be rational for most use cases.
 * We may change this behavior in the future versions.
 *
 * This function overload takes a `ImageBitmap`.
 * For Android drawable resources, you can use another ImageStripKnob() overload that takes the resource ID.
 *
 * ### value label tooltip
 *
 * By default, it shows a value label tooltip when the knob is dragged.
 *
 * It is possible to customize the label behavior by passing `tooltip` argument.
 * It is a `Composable` rendered after the `Image` in a `Column`.
 * You can use `DefaultKnobTooltip` to slightly modify the default behavior, namely:
 *
 *  - always show label (even not on dragging): `DefaultKnobTooltip(value, true)`
 *  - customize text color: `DefaultKnobTooltip(value, knobIsBeingDragged, yourPreferredColor)`
 *  - move up, down, etc. : pass `modifier` to `DefaultKnobTooltip` that is applied to `Text` (visible) or `Box` (invisible space)
 *
 * The knob state is provided as `ImageStripKnobScope` interface.
 *
 * ### Usage example
 *
 * Here is an example use of ImageStripKnob():
 *
 *  ```
 *  var paramValue by remember { mutableStateOf(0f) }
 *  Text("Parameter $paramIndex: ")
 *  ImageStripKnob(
 *      drawableResId = R.drawable.knob_image,
 *      value = paramValue,
 *      onValueChange = {v ->
 *          paramValue = v
 *          println("value at $paramIndex changed: $v")
 *          })
 * ```
 *
 * @param modifier          A `Modifier` to be applied to this knob control.
 * @param imageBitmap       An `ImageBitMap` that contains the knob image strip.
 * @param value             The value that this knob should render for. It should be within the range between `minValue` and `maxValue`.
 * @param valueRange        The value range. It defaults to `0f..1f`.
 * @param explicitSizeInDp  An optional size in Dp if you want an explicit rendered widget size instead of the sizes in image, in `Dp`.
 * @param minSizeInDp       The minimum rendered widget size in `Dp`. It defaults to `48.dp` which is the minimum recommended widget size by Android Accessibility Help.
 * @param fineModeDelayMs   The wait time for "hold to fine mode", in milliseconds. Pass huge value if you want to disable it. 1000 by default.
 * @param tooltipColor      The color of the default implementation of the value label tooltip.
 * @param tooltip           The tooltip Composable which may be rendered in response to user's drag action over this knob.
 * @param onValueChange     An event handler function that takes the changed value. See the documentation for `ImageStripKnob` function for details.
 */

// Make sure to update the default values in Android module too.
@Composable
fun ImageStripKnob(modifier: Modifier = Modifier,
                   imageBitmap: ImageBitmap,
                   value: Float = 0f,
                   valueRange: ClosedFloatingPointRange = 0f..1f,
                   explicitSizeInDp: Dp? = null,
                   minSizeInDp: Dp = defaultKnobMinSizeInDp,
                   fineModeDelayMs: Int = 1000,
                   tooltipColor: Color = Color.Gray,
                   tooltip: @Composable ImageStripKnobScope.() -> Unit = {
             // by default, show tooltip only when it is being dragged
             DefaultKnobTooltip(
                 value = knobValue,
                 showTooltip = knobIsBeingDragged,
                 textColor = tooltipColor
             )
         },
         onValueChange: (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 max = valueRange.endInclusive
    val min = valueRange.start
    val valueDelta = (max - min) / numKnobSlices

    val normalizedValue = if (value > max) max else if (value < min) min else value
    val imageIndex = min(numKnobSlices - 1, ((normalizedValue - min) / valueDelta).toInt())

    var lastDragActionTimeInMillis by remember { mutableStateOf(0L) }
    var inFineHoldMode by remember { mutableStateOf(false) }

    with(LocalDensity.current) {
        var isBeingDragged by remember { mutableStateOf(false) }
        val sizePx = explicitSizeInDp?.toPx() ?: if (minSizeInDp.toPx() > knobSrcSizePx) minSizeInDp.toPx() else knobSrcSizePx.toFloat()

        val draggableState = rememberDraggableState(onDelta = {
            val deltaInDp = it.toDp()

            val now = Clock.System.now().toEpochMilliseconds()
            inFineHoldMode = inFineHoldMode || now - lastDragActionTimeInMillis > fineModeDelayMs
            lastDragActionTimeInMillis = now

            // So far let's assume that 160dp = 1 inch for full motion range.
            // 0.5 inch for half circle-ish.
            val inFineMode = inFineHoldMode // TODO: detect shift key state too i.e. shift+drag is also "fine mode".
            val v = value - deltaInDp.value * valueDelta * 0.5f * if (inFineMode) 0.1f else 1f

            val next = if (v < min) min else if (max < v) max else v
            isBeingDragged = true
            if (value != next)
                onValueChange(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
                            inFineHoldMode = false
                        })
                    .pointerInput(Unit) {
                        // It is registered apart from onDragStarted, as onDragStarted will not be fired until its first motion, not press.
                        awaitPointerEventScope {
                            while (true) {
                                awaitFirstDown()
                                lastDragActionTimeInMillis = Clock.System.now().toEpochMilliseconds()
                            }
                        }
                    }
                    .size(sizePx.toDp())
            )
            Box(Modifier.align(Alignment.CenterHorizontally)) {
                ImageStripKnobScopeData(value, isBeingDragged).tooltip()
            }
        }
    }
}

@Deprecated("Use newer DefaultKnobTooltip() overload that takes valueText",
    ReplaceWith("DefaultKnobTooltip(modifier, showTooltip, value, textColor, null)")
)
@Composable
fun DefaultKnobTooltip(modifier: Modifier = Modifier, showTooltip: Boolean, value: Float, textColor: Color = Color.Gray) =
    DefaultKnobTooltip(modifier, showTooltip, value, textColor, null)

/**
 * The default tooltip `Composable` implementation for `ImageStripKnob`.
 * It is configurable by the arguments.
 *
 * @param modifier      a `Modifier` that is applied to the `Text` (when the label is visible) or `Box` (when it is not).
 * @param showTooltip   The flag to indicate whether the label is shown or not. By default, it is true only if the user is dragging the knob.
 * @param value         The float value to render as the label.
 * @param textColor     a `Color` value to specify at `Text` label.
 * @param valueText     an optional text string that could be used instead of the formatted number. Useful for enumerations.
 */
@Composable
fun DefaultKnobTooltip(modifier: Modifier = Modifier, showTooltip: Boolean, value: Float, textColor: Color = Color.Gray, valueText: String? = null) {
    if (showTooltip)
        Box(modifier) {
            Text(
                valueText ?: formatLabelNumber(value),
                fontSize = 12.sp,
                color = textColor
            )
        }
    else
        with(LocalDensity.current) {
            Box(Modifier.height(16.sp.toDp()).then(modifier))
        }
}

/**
 * This interface is used to provide the state of `ImageStripKnob` for custom tooltip implementation.
 */
@LayoutScopeMarker
@Immutable
interface ImageStripKnobScope {
    /** current value on the knob */
    val knobValue: Float
    /** a flag that indicates whether it is on dragging operation. */
    val knobIsBeingDragged: Boolean
}

internal data class ImageStripKnobScopeData(
    override val knobValue: Float,
    override val knobIsBeingDragged: Boolean)
    : ImageStripKnobScope

/**
 * A custom `Painter` implementation for scaling another source image.
 * It is used by ImageStripKnob to ensure minimum on-screen sizes.
 */
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
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy