Created
May 9, 2026 07:24
-
-
Save ardakazanci/540e7b6bdbe0292da925d58254dae9ec to your computer and use it in GitHub Desktop.
Mesh Gradient Jetpack Compose API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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