Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,58 @@ HueSlider(
)
```

### Custom Thumb

Every slider takes a `thumb` slot, so the Material 3 thumb can be replaced outright — its
size, shape and stroke are yours rather than a fixed set of dimension parameters. The slot
receives the slider's `InteractionSource`, so a thumb can also react to press and drag.

![Custom thumb](docs/images/custom-thumb.png)

```kotlin
private val SquareThumbSize = 48.dp // M3 minimum interactive size

@Composable
fun SquareThumb(color: Color, interaction: InteractionSource) {
val fill = color.copy(alpha = 1f)
val shape = RoundedCornerShape(16.dp)
val ring = lerp(fill, Color.White, 0.6f)

val dragged by interaction.collectIsDraggedAsState()
val pressed by interaction.collectIsPressedAsState()
val elevation by animateDpAsState(if (dragged || pressed) 4.dp else 0.dp)

Box(
Modifier
.size(SquareThumbSize)
.shadow(elevation, shape)
.background(ring, shape)
.padding(5.dp)
.background(fill, RoundedCornerShape(11.dp))
)
}

HueSlider(
state = state,
thumb = { source -> SquareThumb(state.hslColor.toComposeColor(), source) },
thumbWidth = SquareThumbSize, // so the track leaves room for it
)
```

The thumb needs no state parameter: `state` is already in scope at the call site, so the
composable restyles itself as the color changes. The ring is the fill lifted toward white
rather than a light-or-dark choice made at some luminance threshold — a threshold snaps
visibly the moment a drag crosses it, while this moves with the color. And because the
slot is handed the slider's `InteractionSource`, the thumb can react to being dragged;
that is state a caller cannot otherwise reach, since the source is created inside the
slider.

`thumbWidth` matters. The track breaks around the thumb, and it sizes that break from this
value, defaulting to `ColorPickerDefaults.ThumbWidth` (the Material 3 handle). A wider
thumb that does not declare its width covers the gap and sits flush against the gradient.
`thumbTrackGap` controls the clearance itself. The sample app's *Custom thumb* section runs
exactly this code.

### Color Swatch

Renders a color over a transparency checkerboard:
Expand Down
34 changes: 19 additions & 15 deletions colorpicker/api/colorpicker.klib.api

Large diffs are not rendered by default.

60 changes: 31 additions & 29 deletions colorpicker/api/jvm/colorpicker.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ public object ColorPickerDefaults {
/** Size a [codes.side.colorpicker.ui.ColorSwatch] falls back to when given none. */
public val SwatchSize: Dp = 48.dp

/**
* Width the track reserves for the thumb, matching the Material 3 handle. A custom
* thumb wider than this must say so, or the track's gap closes underneath it.
*/
public val ThumbWidth: Dp = 4.dp

/** Clearance left between the thumb and each end of the track. */
public val ThumbTrackGap: Dp = 6.dp

// surfaceBright/surfaceDim keep visible checkerboard contrast in both
// light and dark color schemes.
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package codes.side.colorpicker.ui

import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import codes.side.colorpicker.conversion.toComposeColor
import codes.side.colorpicker.state.ColorPickerState
import codes.side.colorpicker.theme.ColorPickerColors
Expand All @@ -28,6 +30,9 @@ public fun AlphaSlider(
semanticValueText: String? = "${state.hslColor.intAlpha}",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val hsl = state.hslColor
val opaqueColor = remember(hsl.hue, hsl.saturation, hsl.lightness) {
Expand Down Expand Up @@ -58,5 +63,8 @@ public fun AlphaSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package codes.side.colorpicker.ui

import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import codes.side.colorpicker.conversion.toComposeColor
import codes.side.colorpicker.model.CmykColor
import codes.side.colorpicker.state.ColorPickerState
Expand Down Expand Up @@ -38,6 +40,9 @@ public fun CyanSlider(
semanticValueText: String? = "${state.cmykColor.intCyan}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val cmyk = state.cmykColor
val gradientColors = remember(cmyk.magenta, cmyk.yellow, cmyk.key, coloringMode) {
Expand Down Expand Up @@ -73,6 +78,9 @@ public fun CyanSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand All @@ -97,6 +105,9 @@ public fun MagentaSlider(
semanticValueText: String? = "${state.cmykColor.intMagenta}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val cmyk = state.cmykColor
val gradientColors = remember(cmyk.cyan, cmyk.yellow, cmyk.key, coloringMode) {
Expand Down Expand Up @@ -132,6 +143,9 @@ public fun MagentaSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand All @@ -156,6 +170,9 @@ public fun YellowSlider(
semanticValueText: String? = "${state.cmykColor.intYellow}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val cmyk = state.cmykColor
val gradientColors = remember(cmyk.cyan, cmyk.magenta, cmyk.key, coloringMode) {
Expand Down Expand Up @@ -191,6 +208,9 @@ public fun YellowSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand All @@ -215,6 +235,9 @@ public fun KeySlider(
semanticValueText: String? = "${state.cmykColor.intKey}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val cmyk = state.cmykColor
val gradientColors = remember(cmyk.cyan, cmyk.magenta, cmyk.yellow, coloringMode) {
Expand Down Expand Up @@ -250,5 +273,8 @@ public fun KeySlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package codes.side.colorpicker.ui

import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -43,6 +44,15 @@ import kotlinx.collections.immutable.ImmutableList
* announcing the channel's native units instead of a raw fraction.
* @param colors checkerboard colors; see [ColorPickerDefaults.colors].
* @param shapes track shape; see [ColorPickerDefaults.shapes].
* @param thumb optional replacement for the slider thumb. `null` keeps the Material 3
* default thumb tinted with [thumbColor]; pass a composable to control its size, shape
* and stroke entirely. It receives the slider's [InteractionSource], so a thumb can react
* to press and drag; the current value and color need no parameter, since the caller
* already passed them as [value] and [thumbColor].
* @param thumbWidth how much room the track leaves for the thumb. A custom [thumb] wider
* than [ColorPickerDefaults.ThumbWidth] must declare its width here, or the track's gap
* closes and the thumb sits flush against the gradient.
* @param thumbTrackGap clearance between the thumb and each track end.
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -61,8 +71,16 @@ public fun ColorSlider(
semanticValueText: String? = null,
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val interactionSource = remember { MutableInteractionSource() }
val sliderColors = SliderDefaults.colors(
thumbColor = thumbColor.asOpaqueThumb(),
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent,
)

Column(modifier = modifier.fillMaxWidth()) {
if (label != null || valueLabel != null) {
Expand Down Expand Up @@ -99,16 +117,24 @@ public fun ColorSlider(
checkerboardDark = colors.checkerboardDark,
trackShape = shapes.trackShape,
showCheckerboard = showCheckerboard,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
modifier = Modifier
.fillMaxWidth()
.height(trackHeight),
)
},
colors = SliderDefaults.colors(
thumbColor = thumbColor.asOpaqueThumb(),
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent,
),
// The slot takes the InteractionSource rather than M3's SliderState. Two
// reasons: SliderState is experimental, so naming it here would force an
// opt-in on everyone calling ColorSlider, and it is not what a thumb is
// missing. Value and color are already in scope at the call site, captured
// by the lambda; press and drag state is the only thing a caller cannot
// reach, because this source is created here.
thumb = {
if (thumb != null) thumb(interactionSource)
else SliderDefaults.Thumb(interactionSource, colors = sliderColors)
},
colors = sliderColors,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import codes.side.colorpicker.theme.ColorPickerDefaults
import kotlinx.collections.immutable.ImmutableList

// M3 token defaults (from SliderTokens)
private val HandleWidth = 4.dp
private val PressedHandleWidth = 2.dp
private val ThumbTrackGapSize = 6.dp
// M3 token defaults (from SliderTokens). The handle narrows by this much while pressed;
// the same shrink is applied to a custom thumb so the track keeps its M3 feel.
private val PressedWidthReduction = 2.dp
private val TrackInsideCornerSize = 2.dp

/**
Expand All @@ -53,6 +54,8 @@ internal fun GradientTrack(
trackShape: Shape,
modifier: Modifier = Modifier,
showCheckerboard: Boolean = false,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
Expand All @@ -69,7 +72,11 @@ internal fun GradientTrack(
}

val isActive = interactions.isNotEmpty()
val currentThumbWidth = if (isActive) PressedHandleWidth else HandleWidth
val currentThumbWidth = if (isActive) {
(thumbWidth - PressedWidthReduction).coerceAtLeast(0.dp)
} else {
thumbWidth
}

val layoutDirection = LocalLayoutDirection.current
val brush = remember(colors, layoutDirection) {
Expand All @@ -90,7 +97,7 @@ internal fun GradientTrack(

Canvas(modifier = modifier.clip(trackShape)) {
val insideCornerSize = TrackInsideCornerSize.toPx()
val gap = currentThumbWidth.toPx() / 2f + ThumbTrackGapSize.toPx()
val gap = currentThumbWidth.toPx() / 2f + thumbTrackGap.toPx()
val fractionCenter = thumbFraction.coerceIn(0f, 1f) * size.width
// M3 Slider mirrors the thumb position in RTL, so the gap must mirror
// to stay underneath the thumb.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package codes.side.colorpicker.ui

import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import codes.side.colorpicker.conversion.toComposeColor
import codes.side.colorpicker.model.HslColor
import codes.side.colorpicker.state.ColorPickerState
Expand Down Expand Up @@ -58,6 +60,9 @@ public fun HueSlider(
semanticValueText: String? = "${state.hslColor.intHue}°",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val hsl = state.hslColor
val gradientColors = remember(hsl.saturation, hsl.lightness, coloringMode) {
Expand Down Expand Up @@ -90,6 +95,9 @@ public fun HueSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand All @@ -114,6 +122,9 @@ public fun SaturationSlider(
semanticValueText: String? = "${state.hslColor.intSaturation}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val hsl = state.hslColor
val gradientColors = remember(hsl.hue, hsl.lightness, coloringMode) {
Expand Down Expand Up @@ -152,6 +163,9 @@ public fun SaturationSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand All @@ -176,6 +190,9 @@ public fun LightnessSlider(
semanticValueText: String? = "${state.hslColor.intLightness}%",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
) {
val hsl = state.hslColor
val gradientColors = remember(hsl.hue, hsl.saturation, coloringMode) {
Expand Down Expand Up @@ -216,6 +233,9 @@ public fun LightnessSlider(
shapes = shapes,
modifier = modifier,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
)
}

Expand Down
Loading