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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ OkhsvColorPicker(state = state, showAlpha = true)

`ColoringMode` controls the slider gradients: `Independent` shows each channel's full range regardless of the other channels, `Contextual` previews the actual resulting color at each position.

Every slider in a picker is a slot, defaulted to the channel slider it names. Replace one to
relabel it — which is how a picker is localized, since the library ships no strings of its own:

```kotlin
HslColorPicker(
state = state,
hueSlider = { HueSlider(state, label = { Text(stringResource(Res.string.hue)) }) },
)
```

A replacement inherits the picker's colors, shapes and dimensions through the theme, so only
what you actually want to change has to be named. `enabled` is the exception — forward it if
you want your slider dimmed, though the picker refuses input to a disabled slot either way:

```kotlin
HslColorPicker(state = state, enabled = false) // dimmed, inert, and disabled to a screen reader
```

`thumb` reaches every slider in the picker, so [the custom thumb below](#custom-thumb) works
here too rather than only on a slider built by hand.

### Color Planes

`HslPlane` picks both channels at once for the hue currently in `state`,
Expand Down Expand Up @@ -485,7 +506,7 @@ In-progress edits inside the dialog survive configuration changes; passing a new

### Theming

All pickers and sliders accept `colors` and `shapes` built with `ColorPickerDefaults`, which derive from `MaterialTheme` by default:
All pickers and sliders accept `colors`, `shapes` and dimensions built with `ColorPickerDefaults`, which derive from `MaterialTheme` by default:

```kotlin
HslColorPicker(
Expand All @@ -501,6 +522,20 @@ HslColorPicker(
)
```

`ColorPickerTheme` sets them for everything inside it instead, which is how a track height
reaches all twenty-one channel sliders without being a parameter on any of them:

```kotlin
ColorPickerTheme(
dimensions = ColorPickerDefaults.dimensions(trackHeight = 24.dp),
) {
HslColorPicker(state = state)
OkhslPlane(state = state)
}
```

A component reads the theme in its parameter defaults, so an explicit argument still wins over
whatever an enclosing `ColorPickerTheme` provided.
## 🔗 State Management

`ColorPickerState` is the single source of truth. It reads and writes each color space natively, with no round-trip conversions.
Expand Down
119 changes: 84 additions & 35 deletions colorpicker/api/colorpicker.klib.api

Large diffs are not rendered by default.

149 changes: 93 additions & 56 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 @@ -9,9 +9,18 @@ import androidx.compose.ui.graphics.Color
*
* @property checkerboardLight color of the light cells of the transparency checkerboard.
* @property checkerboardDark color of the dark cells of the transparency checkerboard.
* @property disabledAlpha opacity a disabled control draws at, `1f` to leave it alone. One value
* rather than a parallel set of disabled colors: a picker's track is a gradient of the colors
* being chosen, so there is no fixed color to swap it for.
* @property disabledSaturation how much colour a disabled control keeps, `0f` for grey and `1f`
* to leave it alone. Dimming shows paler versions of real colours and composites against a
* background the library does not own; draining the colour does neither. Set both, either, or
* neither.
*/
@Immutable
public data class ColorPickerColors(
public val checkerboardLight: Color,
public val checkerboardDark: Color,
public val disabledAlpha: Float,
public val disabledSaturation: Float,
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public object ColorPickerDefaults {
/** Diameter of a [codes.side.colorpicker.ui.ColorPlane]'s position indicator. */
public val PlaneThumbSize: Dp = 24.dp

/** Opacity a disabled component draws at, the Material 3 disabled content value. */
public const val DisabledAlpha: Float = 0.38f

/**
* Colour a disabled component keeps. Full, by default: dimming alone is the Material
* convention, and draining the colour as well is a choice a caller makes.
*/
public const val DisabledSaturation: Float = 1f

// surfaceBright/surfaceDim keep visible checkerboard contrast in both
// light and dark color schemes.
/**
Expand All @@ -49,9 +58,49 @@ public object ColorPickerDefaults {
public fun colors(
checkerboardLight: Color = MaterialTheme.colorScheme.surfaceBright,
checkerboardDark: Color = MaterialTheme.colorScheme.surfaceDim,
disabledAlpha: Float = DisabledAlpha,
disabledSaturation: Float = DisabledSaturation,
): ColorPickerColors = ColorPickerColors(
checkerboardLight = checkerboardLight,
checkerboardDark = checkerboardDark,
disabledAlpha = disabledAlpha,
disabledSaturation = disabledSaturation,
)

/**
* The colors in force here: whatever an enclosing [ColorPickerTheme] provided, or [colors]
* when nothing did.
*
* Every component reads this in a parameter default rather than in its body, so an explicit
* argument still wins and a component composed inside a picker inherits the picker's theme
* without the call site forwarding it.
*/
@Composable
public fun currentColors(): ColorPickerColors = LocalColorPickerColors.current ?: colors()

/** The shapes in force here; see [currentColors]. */
@Composable
public fun currentShapes(): ColorPickerShapes = LocalColorPickerShapes.current ?: shapes()

/** The dimensions in force here; see [currentColors]. */
@Composable
public fun currentDimensions(): ColorPickerDimensions =
LocalColorPickerDimensions.current ?: dimensions()

/** Creates a [ColorPickerDimensions] from the constants above. */
@Composable
public fun dimensions(
trackHeight: Dp = TrackHeight,
thumbWidth: Dp = ThumbWidth,
thumbTrackGap: Dp = ThumbTrackGap,
planeMinSize: Dp = PlaneMinSize,
planeThumbSize: Dp = PlaneThumbSize,
): ColorPickerDimensions = ColorPickerDimensions(
trackHeight = trackHeight,
thumbWidth = thumbWidth,
thumbTrackGap = thumbTrackGap,
planeMinSize = planeMinSize,
planeThumbSize = planeThumbSize,
)

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package codes.side.colorpicker.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp

/**
* Dimensions used by color picker components. Obtain instances via
* [ColorPickerDefaults.dimensions] so defaults come from a single source.
*
* These travel with the theme rather than as parameters because they apply to every slider a
* picker draws. A caller wanting a taller track sets it once here instead of on each of the
* twenty-one channel sliders.
*
* @property trackHeight height of a slider's gradient track.
* @property thumbWidth width the track reserves for the thumb. A custom thumb wider than this
* must say so, or the track's gap closes underneath it.
* @property thumbTrackGap clearance left between the thumb and each end of the track.
* @property planeMinSize size a [codes.side.colorpicker.ui.ColorPlane] falls back to.
* @property planeThumbSize diameter of a [codes.side.colorpicker.ui.ColorPlane]'s indicator.
*/
@Immutable
public data class ColorPickerDimensions(
public val trackHeight: Dp,
public val thumbWidth: Dp,
public val thumbTrackGap: Dp,
public val planeMinSize: Dp,
public val planeThumbSize: Dp,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package codes.side.colorpicker.theme

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf

/**
* Colors every picker component below this point uses unless given some of its own.
*
* `null` means nothing has been provided and the component falls back to
* [ColorPickerDefaults.colors], which reads `MaterialTheme` at the point of use. Every
* component reads this in a parameter default rather than in its body, so an explicit
* argument always wins over what an ancestor provided.
*/
public val LocalColorPickerColors: ProvidableCompositionLocal<ColorPickerColors?> =
compositionLocalOf { null }

/** Shapes every picker component below this point uses; see [LocalColorPickerColors]. */
public val LocalColorPickerShapes: ProvidableCompositionLocal<ColorPickerShapes?> =
compositionLocalOf { null }

/** Dimensions every picker component below this point uses; see [LocalColorPickerColors]. */
public val LocalColorPickerDimensions: ProvidableCompositionLocal<ColorPickerDimensions?> =
compositionLocalOf { null }

/**
* Provides [colors], [shapes] and [dimensions] to everything in [content].
*
* Wrap a screen in this to style every picker on it at once. A ready-made picker does the same
* for its own sliders, which is what lets a replaced slider slot inherit them without the call
* site forwarding anything.
*/
@Composable
public fun ColorPickerTheme(
colors: ColorPickerColors = ColorPickerDefaults.currentColors(),
shapes: ColorPickerShapes = ColorPickerDefaults.currentShapes(),
dimensions: ColorPickerDimensions = ColorPickerDefaults.currentDimensions(),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalColorPickerColors provides colors,
LocalColorPickerShapes provides shapes,
LocalColorPickerDimensions provides dimensions,
content = content,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@ import kotlinx.collections.immutable.persistentListOf
public fun AlphaSlider(
state: ColorPickerState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: (@Composable () -> Unit)? = { SliderLabel("Alpha") },
valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.hslColor.intAlpha}") },
semanticLabel: String? = "Alpha",
semanticValueText: String? = "${state.hslColor.intAlpha}",
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
colors: ColorPickerColors = ColorPickerDefaults.currentColors(),
shapes: ColorPickerShapes = ColorPickerDefaults.currentShapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
thumbWidth: Dp = ColorPickerDefaults.currentDimensions().thumbWidth,
thumbTrackGap: Dp = ColorPickerDefaults.currentDimensions().thumbTrackGap,
) {
val hsl = state.hslColor
val opaqueColor = remember(hsl.hue, hsl.saturation, hsl.lightness) {
Expand Down Expand Up @@ -62,6 +63,7 @@ public fun AlphaSlider(
colors = colors,
shapes = shapes,
modifier = modifier,
enabled = enabled,
onValueChangeFinished = { interaction.end() },
thumb = thumb,
thumbWidth = thumbWidth,
Expand Down
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
Expand All @@ -10,36 +11,60 @@ import codes.side.colorpicker.state.ColoringMode
import codes.side.colorpicker.theme.ColorPickerColors
import codes.side.colorpicker.theme.ColorPickerDefaults
import codes.side.colorpicker.theme.ColorPickerShapes
import codes.side.colorpicker.theme.ColorPickerTheme

/**
* Complete CMYK picker: cyan, magenta, yellow, and key sliders, plus an optional
* alpha slider.
*
* Each slider is a slot, defaulted to the channel slider it names. Replace one to relabel or
* restyle that channel: whatever is passed inherits this picker's colors, shapes and
* dimensions through the theme, and is dimmed only if [enabled] is forwarded to it — though
* it is refused input either way.
*
* @param showAlpha whether to include the [AlphaSlider].
* @param coloringMode defaults to [ColoringMode.Contextual] so each track previews
* the resulting color at the current values of the other channels.
* @param colors checkerboard colors; see [ColorPickerDefaults.colors].
* @param shapes track shape; see [ColorPickerDefaults.shapes].
* @param enabled when `false` the picker is dimmed, refuses input, and reports itself
* disabled to accessibility. Input is refused by the picker as well as by each slider, so a
* replaced slider slot cannot stay live even if the call site did not forward this to it.
* @param thumb optional replacement for every slider's thumb; see [ColorSlider].
* @param cyanSlider slot for the cyan channel; defaults to [CyanSlider].
* @param magentaSlider slot for the magenta channel; defaults to [MagentaSlider].
* @param yellowSlider slot for the yellow channel; defaults to [YellowSlider].
* @param keySlider slot for the key channel; defaults to [KeySlider].
* @param alphaSlider the [AlphaSlider], shown only when [showAlpha] is `true`.
*/
@Composable
public fun CmykColorPicker(
state: ColorPickerState,
modifier: Modifier = Modifier,
showAlpha: Boolean = true,
enabled: Boolean = true,
coloringMode: ColoringMode = ColoringMode.Contextual,
colors: ColorPickerColors = ColorPickerDefaults.colors(),
shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
colors: ColorPickerColors = ColorPickerDefaults.currentColors(),
shapes: ColorPickerShapes = ColorPickerDefaults.currentShapes(),
thumb: (@Composable (InteractionSource) -> Unit)? = null,
cyanSlider: @Composable () -> Unit = { CyanSlider(state, enabled = enabled, coloringMode = coloringMode, thumb = thumb) },
magentaSlider: @Composable () -> Unit = { MagentaSlider(state, enabled = enabled, coloringMode = coloringMode, thumb = thumb) },
yellowSlider: @Composable () -> Unit = { YellowSlider(state, enabled = enabled, coloringMode = coloringMode, thumb = thumb) },
keySlider: @Composable () -> Unit = { KeySlider(state, enabled = enabled, coloringMode = coloringMode, thumb = thumb) },
alphaSlider: @Composable () -> Unit = { AlphaSlider(state, enabled = enabled, thumb = thumb) },
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CyanSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
MagentaSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
YellowSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
KeySlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
if (showAlpha) {
AlphaSlider(state = state, colors = colors, shapes = shapes)
// Provided rather than passed down, so a replaced slider slot inherits the picker's
// theme without the call site forwarding it.
ColorPickerTheme(colors = colors, shapes = shapes) {
Column(
modifier = modifier.disabledInput(enabled),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
cyanSlider()
magentaSlider()
yellowSlider()
keySlider()
if (showAlpha) alphaSlider()
}
}
}
Loading