Skip to content

Instantly share code, notes, and snippets.

@ardakazanci
Created May 9, 2026 07:24
Show Gist options
  • Select an option

  • Save ardakazanci/540e7b6bdbe0292da925d58254dae9ec to your computer and use it in GitHub Desktop.

Select an option

Save ardakazanci/540e7b6bdbe0292da925d58254dae9ec to your computer and use it in GitHub Desktop.
Mesh Gradient Jetpack Compose API
private const val Tau = 6.2831855f
private const val MeshRows = 3
private const val MeshColumns = 3
private val FieldBaseColor = Color(0xFFFF3E6B)
private val ControlPanelColor = Color(0x99350716)
private val ControlBorderColor = Color.White.copy(alpha = 0.3f)
private val RibbonPalette = listOf(
Color(0xFFFF1F5B),
Color(0xFFFF5F78),
Color(0xFFFF9BB8),
Color(0xFF20D6FF),
Color(0xFFFF2E75),
)
private enum class DragMode {
RotateScale,
Move,
}
private data class ActiveDrag(
val index: Int,
val mode: DragMode,
)
@Immutable
private data class AngleVector(
val label: String,
val angleDegrees: Float,
val pivot: Offset,
val length: Float,
val color: Color,
)
@Composable
fun MeshGradientConceptApp() {
var thermalAngle by rememberSaveable { mutableFloatStateOf(78f) }
var lightAngle by rememberSaveable { mutableFloatStateOf(112f) }
var depthAngle by rememberSaveable { mutableFloatStateOf(64f) }
var thermalPivotX by rememberSaveable { mutableFloatStateOf(0.25f) }
var thermalPivotY by rememberSaveable { mutableFloatStateOf(0.7f) }
var lightPivotX by rememberSaveable { mutableFloatStateOf(0.52f) }
var lightPivotY by rememberSaveable { mutableFloatStateOf(0.42f) }
var depthPivotX by rememberSaveable { mutableFloatStateOf(0.78f) }
var depthPivotY by rememberSaveable { mutableFloatStateOf(0.65f) }
var thermalLength by rememberSaveable { mutableFloatStateOf(0.34f) }
var lightLength by rememberSaveable { mutableFloatStateOf(0.31f) }
var depthLength by rememberSaveable { mutableFloatStateOf(0.32f) }
var showAngles by rememberSaveable { mutableStateOf(true) }
val angleVectors = listOf(
AngleVector(
label = "Thermal",
angleDegrees = thermalAngle,
pivot = Offset(thermalPivotX, thermalPivotY),
length = thermalLength,
color = Color(0xFFFF185D),
),
AngleVector(
label = "Light",
angleDegrees = lightAngle,
pivot = Offset(lightPivotX, lightPivotY),
length = lightLength,
color = Color(0xFF20D6FF),
),
AngleVector(
label = "Depth",
angleDegrees = depthAngle,
pivot = Offset(depthPivotX, depthPivotY),
length = depthLength,
color = Color(0xFFFF536E),
),
)
AngleFieldScreen(
angleVectors = angleVectors,
showAngles = showAngles,
onVectorChange = { index, degrees, pivot, length ->
when (index) {
0 -> {
thermalAngle = degrees
thermalPivotX = pivot.x
thermalPivotY = pivot.y
thermalLength = length
}
1 -> {
lightAngle = degrees
lightPivotX = pivot.x
lightPivotY = pivot.y
lightLength = length
}
2 -> {
depthAngle = degrees
depthPivotX = pivot.x
depthPivotY = pivot.y
depthLength = length
}
}
},
onToggleAngles = { showAngles = !showAngles },
)
}
@Composable
private fun AngleFieldScreen(
angleVectors: List<AngleVector>,
showAngles: Boolean,
onVectorChange: (Int, Float, Offset, Float) -> Unit,
onToggleAngles: () -> Unit,
) {
var fieldSize by remember { mutableStateOf(IntSize.Zero) }
var activeVectorIndex by remember { mutableStateOf<Int?>(null) }
Box(
modifier = Modifier
.fillMaxSize()
.background(FieldBaseColor)
.onSizeChanged { fieldSize = it },
) {
Box(
modifier = Modifier
.matchParentSize()
.meshGradient(rows = MeshRows, columns = MeshColumns, hasBicubicColor = true) {
buildAngleMesh(angleVectors)
},
)
if (showAngles) {
AngleGuideLayer(
angleVectors = angleVectors,
fieldSize = fieldSize,
activeVectorIndex = activeVectorIndex,
onActiveVectorChange = { activeVectorIndex = it },
onVectorChange = onVectorChange,
)
}
AngleFieldHud(
angleVectors = angleVectors,
showAngles = showAngles,
onToggleAngles = onToggleAngles,
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(20.dp),
)
}
}
@Composable
private fun AngleGuideLayer(
angleVectors: List<AngleVector>,
fieldSize: IntSize,
activeVectorIndex: Int?,
onActiveVectorChange: (Int?) -> Unit,
onVectorChange: (Int, Float, Offset, Float) -> Unit,
) {
val latestVectors by rememberUpdatedState(angleVectors)
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
var activeDrag: ActiveDrag? = null
detectDragGestures(
onDragStart = { position ->
activeDrag = dragTargetFor(position, size, latestVectors)
onActiveVectorChange(activeDrag?.index)
},
onDragEnd = {
activeDrag = null
onActiveVectorChange(null)
},
onDragCancel = {
activeDrag = null
onActiveVectorChange(null)
},
onDrag = { change, dragAmount ->
val drag = activeDrag ?: dragTargetFor(
position = change.position,
size = size,
angleVectors = latestVectors,
)?.also {
activeDrag = it
onActiveVectorChange(it.index)
}
if (drag != null) {
val vector = latestVectors[drag.index]
val drawSize = Size(size.width.toFloat(), size.height.toFloat())
val minDimension = min(drawSize.width, drawSize.height)
when (drag.mode) {
DragMode.RotateScale -> {
val pivot = vector.pivot.toPx(drawSize)
val degrees = angleFromPivot(pivot, change.position)
val length = (pivot.distanceTo(change.position) / minDimension)
.coerceIn(0.13f, 0.44f)
onVectorChange(drag.index, degrees, vector.pivot, length)
}
DragMode.Move -> {
val nextPivot = Offset(
x = (vector.pivot.x + dragAmount.x / size.width.toFloat()).coerceIn(0.12f, 0.88f),
y = (vector.pivot.y + dragAmount.y / size.height.toFloat()).coerceIn(0.18f, 0.86f),
)
onVectorChange(
drag.index,
vector.angleDegrees,
nextPivot,
vector.length,
)
}
}
change.consume()
}
},
)
},
) {
Canvas(modifier = Modifier.matchParentSize()) {
angleVectors.forEachIndexed { index, vector ->
val isActive = index == activeVectorIndex
val pivot = vector.pivot.toPx(size)
val end = vector.endPoint(size)
val referenceEnd = pivot + Offset(vector.length * min(size.width, size.height), 0f)
val arcRadius = vector.length * min(size.width, size.height) * 0.42f
val strokeWidth = if (isActive) 4.dp.toPx() else 2.5.dp.toPx()
drawLine(
color = Color.White.copy(alpha = 0.42f),
start = pivot,
end = referenceEnd,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
)
drawArc(
color = vector.color.copy(alpha = if (isActive) 0.96f else 0.72f),
startAngle = 0f,
sweepAngle = -vector.angleDegrees.normalizedDegrees(),
useCenter = false,
topLeft = Offset(pivot.x - arcRadius, pivot.y - arcRadius),
size = Size(arcRadius * 2f, arcRadius * 2f),
style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round),
)
drawLine(
color = vector.color.copy(alpha = if (isActive) 1f else 0.88f),
start = pivot,
end = end,
strokeWidth = strokeWidth,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White.copy(alpha = 0.32f),
radius = if (isActive) 18.dp.toPx() else 14.dp.toPx(),
center = end,
)
drawCircle(
color = vector.color,
radius = if (isActive) 12.dp.toPx() else 9.dp.toPx(),
center = end,
)
drawCircle(
color = Color.White,
radius = 3.dp.toPx(),
center = end,
)
drawCircle(
color = vector.color.copy(alpha = if (isActive) 0.88f else 0.52f),
radius = if (isActive) 8.dp.toPx() else 6.dp.toPx(),
center = pivot,
)
drawCircle(
color = Color.White,
radius = 2.5.dp.toPx(),
center = pivot,
)
}
}
if (fieldSize != IntSize.Zero) {
angleVectors.forEachIndexed { index, vector ->
AngleBadge(
vector = vector,
isActive = index == activeVectorIndex,
fieldSize = fieldSize,
)
}
}
}
}
@Composable
private fun AngleBadge(
vector: AngleVector,
isActive: Boolean,
fieldSize: IntSize,
) {
val labelOffset = remember(vector, fieldSize) {
vector.labelOffset(fieldSize)
}
Surface(
modifier = Modifier.offset { labelOffset },
color = ControlPanelColor,
contentColor = Color.White,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(
width = 1.dp,
color = if (isActive) vector.color else ControlBorderColor,
),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(9.dp)
.background(vector.color, RoundedCornerShape(3.dp)),
)
Text(
text = "${vector.label} ${vector.angleDegrees.normalizedDegrees().roundToInt()} deg / ${(vector.length * 100).roundToInt()}%",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun AngleFieldHud(
angleVectors: List<AngleVector>,
showAngles: Boolean,
onToggleAngles: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Angle Field",
style = MaterialTheme.typography.displaySmall,
color = Color.White,
fontWeight = FontWeight.Bold,
)
Text(
text = "Static mesh. Drag tips for angle and length; drag centers to move.",
style = MaterialTheme.typography.titleMedium,
color = Color.White.copy(alpha = 0.76f),
fontWeight = FontWeight.Medium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
angleVectors.forEach { vector ->
AngleReadout(vector)
}
}
Surface(
modifier = Modifier.clickable(onClick = onToggleAngles),
color = if (showAngles) Color.White.copy(alpha = 0.16f) else ControlPanelColor,
contentColor = Color.White,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, ControlBorderColor),
) {
Text(
text = if (showAngles) "Hide angles" else "Show angles",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun AngleReadout(vector: AngleVector) {
Surface(
color = ControlPanelColor,
contentColor = Color.White,
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, ControlBorderColor),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.background(vector.color, RoundedCornerShape(3.dp)),
)
Text(
text = "${vector.angleDegrees.normalizedDegrees().roundToInt()} / ${(vector.length * 100).roundToInt()}",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
private fun MeshGradientScope.buildAngleMesh(angleVectors: List<AngleVector>) {
val primary = angleVectors.first().direction()
val secondary = angleVectors.getOrNull(1)?.direction() ?: Offset.Zero
for (row in 0..rows) {
for (column in 0..columns) {
val rowFraction = row / rows.toFloat()
val columnFraction = column / columns.toFloat()
val isHorizontalEdge = row == 0 || row == rows
val isVerticalEdge = column == 0 || column == columns
val centered = Offset(columnFraction - 0.5f, rowFraction - 0.5f)
val flow = angleVectors.foldIndexed(Offset.Zero) { index, acc, vector ->
val direction = vector.direction()
val projection = centered.x * direction.x + centered.y * direction.y
val wave = sin(projection * Tau * 1.15f + vector.angleDegrees.toRadians() + index * 1.25f)
acc + direction * wave * (0.012f + vector.length * 0.045f)
}
val x = if (isVerticalEdge || isHorizontalEdge) {
columnFraction
} else {
(columnFraction + flow.x).coerceIn(0.05f, 0.95f)
}
val y = if (isHorizontalEdge || isVerticalEdge) {
rowFraction
} else {
(rowFraction + flow.y).coerceIn(0.05f, 0.95f)
}
val rightTangentY = if (isHorizontalEdge) 0f else primary.y * 0.045f + flow.y * 0.55f
val bottomTangentX = if (isVerticalEdge) 0f else secondary.x * 0.045f + flow.x * 0.55f
setVertex(
row = row,
column = column,
position = Offset(x, y),
color = angleMeshColor(centered, angleVectors),
rightControlPoint = Offset(0.22f / columns, rightTangentY),
bottomControlPoint = Offset(bottomTangentX, 0.22f / rows),
)
}
}
}
private fun angleMeshColor(centered: Offset, angleVectors: List<AngleVector>): Color {
val colorOrbit = angleVectors.foldIndexed(centered.x * 0.52f - centered.y * 0.18f) { index, acc, vector ->
val direction = vector.direction()
val projection = centered.x * direction.x + centered.y * direction.y
acc + sin(projection * Tau * (0.95f + vector.length) + vector.angleDegrees.toRadians() + index) *
(0.12f + vector.length * 0.35f)
}
val normalizedOrbit = wrap(colorOrbit)
val scaledIndex = normalizedOrbit * RibbonPalette.size
val startIndex = floor(scaledIndex).toInt() % RibbonPalette.size
val endIndex = (startIndex + 1) % RibbonPalette.size
val fraction = scaledIndex - floor(scaledIndex)
val vividColor = lerp(RibbonPalette[startIndex], RibbonPalette[endIndex], fraction)
val highlight = ((sin((centered.x * 1.7f - centered.y * 0.55f) * Tau + angleVectors[1].angleDegrees.toRadians()) + 1f) / 2f)
.coerceIn(0f, 1f)
val highlightColor = lerp(vividColor, Color(0xFFFFE0EA), ((highlight - 0.62f) / 0.38f).coerceIn(0f, 1f) * 0.42f)
return lerp(FieldBaseColor, highlightColor, 0.96f)
}
private fun dragTargetFor(
position: Offset,
size: IntSize,
angleVectors: List<AngleVector>,
): ActiveDrag? {
val drawSize = Size(size.width.toFloat(), size.height.toFloat())
val minDimension = min(drawSize.width, drawSize.height)
val handleHitRadius = minDimension * 0.105f
val pivotHitRadius = minDimension * 0.08f
val lineHitRadius = minDimension * 0.045f
val nearestHandle = angleVectors
.mapIndexed { index, vector ->
index to vector.endPoint(drawSize).distanceTo(position)
}
.minByOrNull { it.second }
if (nearestHandle != null && nearestHandle.second <= handleHitRadius) {
return ActiveDrag(nearestHandle.first, DragMode.RotateScale)
}
val nearestPivot = angleVectors
.mapIndexed { index, vector ->
index to vector.pivot.toPx(drawSize).distanceTo(position)
}
.minByOrNull { it.second }
if (nearestPivot != null && nearestPivot.second <= pivotHitRadius) {
return ActiveDrag(nearestPivot.first, DragMode.Move)
}
val nearestLine = angleVectors
.mapIndexed { index, vector ->
val pivot = vector.pivot.toPx(drawSize)
val end = vector.endPoint(drawSize)
index to position.distanceToSegment(pivot, end)
}
.minByOrNull { it.second }
if (nearestLine != null && nearestLine.second <= lineHitRadius) {
return ActiveDrag(nearestLine.first, DragMode.Move)
}
return null
}
private fun angleFromPivot(pivot: Offset, position: Offset): Float {
return (atan2(pivot.y - position.y, position.x - pivot.x) * 180f / PI.toFloat())
.normalizedDegrees()
}
private fun AngleVector.direction(): Offset {
val radians = angleDegrees.toRadians()
return Offset(cos(radians), -sin(radians))
}
private fun AngleVector.endPoint(size: Size): Offset {
val pivotPx = pivot.toPx(size)
val radius = length * min(size.width, size.height)
val direction = direction()
return pivotPx + direction * radius
}
private fun AngleVector.labelOffset(fieldSize: IntSize): IntOffset {
val drawSize = Size(fieldSize.width.toFloat(), fieldSize.height.toFloat())
val end = endPoint(drawSize)
val direction = direction()
val rawX = end.x + direction.x * 18f
val rawY = end.y + direction.y * 18f
return IntOffset(
x = rawX.roundToInt().coerceIn(12, (fieldSize.width - 132).coerceAtLeast(12)),
y = rawY.roundToInt().coerceIn(132, (fieldSize.height - 58).coerceAtLeast(132)),
)
}
private fun Offset.toPx(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun Offset.toPx(size: IntSize): Offset {
return Offset(x * size.width, y * size.height)
}
private fun Offset.distanceTo(other: Offset): Float {
return hypot(x - other.x, y - other.y)
}
private fun Offset.distanceToSegment(start: Offset, end: Offset): Float {
val segment = end - start
val segmentLengthSquared = segment.x * segment.x + segment.y * segment.y
if (segmentLengthSquared == 0f) return distanceTo(start)
val projection = ((x - start.x) * segment.x + (y - start.y) * segment.y) /
segmentLengthSquared
val clampedProjection = projection.coerceIn(0f, 1f)
val closest = Offset(
x = start.x + segment.x * clampedProjection,
y = start.y + segment.y * clampedProjection,
)
return distanceTo(closest)
}
private fun Float.toRadians(): Float {
return this / 180f * PI.toFloat()
}
private fun Float.normalizedDegrees(): Float {
val result = this % 360f
return if (result < 0f) result + 360f else result
}
private fun wrap(value: Float): Float {
return value - floor(value)
}
@Preview(showBackground = true)
@Composable
private fun MeshGradientConceptPreview() {
MeshGradientTheme(dynamicColor = false) {
MeshGradientConceptApp()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment