Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active October 28, 2025 11:06
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/e29c2929faf0b8bf84c4d0109b159cbb to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/e29c2929faf0b8bf84c4d0109b159cbb to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.annotation.SuppressLint
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/* -------------------------------------------------------
* Palette
* ---------------------------------------------------- */
private val NeoBg = Color(0xFFE9EDF6)
private val NeoIcon = Color(0xFF737F92)
private val NeoDark = Color(0xFF2A3B55)
private val NeoLight = Color.White
/* -------------------------------------------------------
* Shadow helpers (composition-safe order)
*
* Raised: dropShadow → clip → background
* Inset : clip → background → innerShadow
* ---------------------------------------------------- */
private fun Modifier.neoRaised(shape: Shape) = this
.dropShadow(
shape,
Shadow(
radius = 2.dp,
spread = 0.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
.dropShadow(
shape,
Shadow(radius = 4.dp, spread = 0.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.dropShadow(
shape,
Shadow(
radius = 3.dp,
spread = 0.dp,
offset = DpOffset(2.dp, 3.dp),
color = NeoDark.copy(alpha = 0.10f)
)
)
private fun Modifier.neoInset(shape: Shape) = this
.innerShadow(
shape,
Shadow(radius = 5.dp, spread = 0.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.innerShadow(
shape,
Shadow(
radius = 2.dp,
spread = 0.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.40f)
)
)
/* -------------------------------------------------------
* Icon (toggle: raised ↔ inset)
* ---------------------------------------------------- */
@Composable
fun NeoCircleIconToggle(
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
size: Dp = 88.dp
) {
var pressed by remember { mutableStateOf(false) }
val shape = CircleShape
Box(
modifier = modifier
.requiredSize(size)
.then(
if (!pressed) {
Modifier
.dropShadow(
shape,
Shadow(
radius = 2.dp,
spread = 0.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
.dropShadow(
shape,
Shadow(
radius = 4.dp,
spread = 0.dp,
offset = DpOffset(-5.dp, -5.dp),
color = NeoLight
)
)
.dropShadow(
shape,
Shadow(
radius = 3.dp,
spread = 0.dp,
offset = DpOffset(2.dp, 3.dp),
color = NeoDark.copy(alpha = 0.10f)
)
)
.clip(shape)
.background(NeoBg)
} else {
Modifier
.clip(shape)
.background(NeoBg)
.innerShadow(
shape, Shadow(
radius = 5.dp,
spread = 0.dp,
offset = DpOffset(-5.dp, -5.dp),
color = NeoLight
)
)
.innerShadow(
shape, Shadow(
radius = 3.dp,
spread = 0.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.40f)
)
)
}
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { pressed = !pressed },
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription, tint = NeoIcon, modifier = Modifier.size(size * 0.34f))
}
}
/* -------------------------------------------------------
* Switch
* ---------------------------------------------------- */
@Composable
fun NeoSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
width: Dp = 64.dp,
height: Dp = 36.dp
) {
val trackShape = RoundedCornerShape(percent = 50)
val thumbSize = height - 10.dp
val thumbShape = CircleShape
Box(
modifier = modifier
.size(width, height)
.clip(trackShape)
.background(NeoBg)
.innerShadow(
trackShape,
Shadow(radius = 7.dp, offset = DpOffset(-4.dp, -4.dp), color = NeoLight)
)
.innerShadow(
trackShape,
Shadow(
radius = 2.dp,
offset = DpOffset(4.dp, 4.dp),
color = NeoDark.copy(alpha = 0.40f)
)
)
.clickable(
remember { MutableInteractionSource() },
indication = null
) { onCheckedChange(!checked) },
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.padding(start = 5.dp, end = 5.dp)
.size(thumbSize)
.then(if (!checked) Modifier else Modifier.offset(x = width - thumbSize - 10.dp))
.dropShadow(
thumbShape,
Shadow(
radius = 2.dp,
offset = DpOffset(4.dp, 3.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
.dropShadow(
thumbShape,
Shadow(radius = 4.dp, offset = DpOffset(-4.dp, -3.dp), color = NeoLight)
)
.dropShadow(
thumbShape,
Shadow(
radius = 3.dp,
offset = DpOffset(2.dp, 3.dp),
color = NeoDark.copy(alpha = 0.10f)
)
)
.clip(thumbShape)
.background(NeoBg)
)
}
}
/* -------------------------------------------------------
* Slider
* ---------------------------------------------------- */
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun NeoSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
height: Dp = 48.dp,
thumbSize: Dp = 28.dp,
horizontalPadding: Dp = 16.dp
) {
val trackShape = RoundedCornerShape(percent = 50)
val thumbShape = CircleShape
val density = LocalDensity.current
var widthPx by remember { mutableStateOf(0f) }
val thumbPx = with(density) { thumbSize.toPx() }
val padPx = with(density) { horizontalPadding.toPx() }
val travelPx = (widthPx - 2 * padPx - thumbPx).coerceAtLeast(0f)
val clamped = value.coerceIn(0f, 1f)
val xPx = clamped * travelPx
val dragState = rememberDraggableState { delta ->
if (travelPx > 0f) {
val newPx = (xPx + delta).coerceIn(0f, travelPx)
onValueChange(newPx / travelPx)
}
}
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.height(height)
.onSizeChanged { widthPx = it.width.toFloat() }
.clip(trackShape)
.background(NeoBg)
.innerShadow(
trackShape,
Shadow(radius = 5.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.innerShadow(
trackShape,
Shadow(
radius = 2.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.40f)
)
)
.padding(horizontal = horizontalPadding),
contentAlignment = Alignment.CenterStart
) {
// Inner groove
Box(
modifier = Modifier
.fillMaxWidth()
.height(10.dp)
.clip(trackShape)
.background(NeoBg)
.innerShadow(
trackShape,
Shadow(radius = 3.dp, offset = DpOffset(-2.dp, -2.dp), color = NeoLight)
)
.innerShadow(
trackShape,
Shadow(
radius = 2.dp,
offset = DpOffset(2.dp, 2.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
)
// Progress fill (slightly inset for legibility)
val progressWidth = with(LocalDensity.current) { (xPx + thumbPx / 2f).toDp() }
Box(
modifier = Modifier
.height(10.dp)
.width(progressWidth.coerceAtLeast(0.dp))
.clip(trackShape)
.background(NeoBg)
.innerShadow(
trackShape,
Shadow(
radius = 3.dp,
offset = DpOffset(1.dp, 1.dp),
color = NeoDark.copy(alpha = 0.18f)
)
)
)
// Thumb (raised)
Box(
modifier = Modifier
.offset { IntOffset(xPx.roundToInt(), 0) }
.size(thumbSize)
.dropShadow(
thumbShape,
Shadow(
radius = 2.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
.dropShadow(
thumbShape,
Shadow(radius = 4.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.dropShadow(
thumbShape,
Shadow(
radius = 3.dp,
offset = DpOffset(2.dp, 3.dp),
color = NeoDark.copy(alpha = 0.10f)
)
)
.clip(thumbShape)
.background(NeoBg)
.draggable(state = dragState, orientation = Orientation.Horizontal)
.pointerInput(travelPx) {
detectTapGestures { offset ->
if (travelPx > 0f) {
val newPx = (offset.x - padPx - thumbPx / 2f).coerceIn(0f, travelPx)
onValueChange(newPx / travelPx)
}
}
}
)
}
}
/* -------------------------------------------------------
* Checkbox, Radio, Chip, Button
* ---------------------------------------------------- */
@Composable
fun NeoCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
size: Dp = 28.dp
) {
val shape = RoundedCornerShape(6.dp)
Box(
modifier = modifier
.requiredSize(size)
.then(
if (!checked) {
Modifier
.neoRaised(shape)
.clip(shape)
.background(NeoBg)
} else {
Modifier
.clip(shape)
.background(NeoBg)
.neoInset(shape)
}
)
.clickable(
remember { MutableInteractionSource() },
indication = null
) { onCheckedChange(!checked) },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = NeoDark.copy(alpha = 0.45f),
modifier = Modifier.size(size * 0.85f)
)
}
}
}
@Composable
fun NeoTextButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
pressed: Boolean = false,
paddingH: Dp = 20.dp,
height: Dp = 40.dp
) {
val shape = RoundedCornerShape(999.dp)
Box(
modifier = modifier
.height(height)
.then(
if (!pressed) {
Modifier
.neoRaised(shape)
.clip(shape)
.background(NeoBg)
} else {
Modifier
.clip(shape)
.background(NeoBg)
.neoInset(shape)
}
)
.clickable(
remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = paddingH),
contentAlignment = Alignment.Center
) {
Text(text, color = NeoIcon)
}
}
@Composable
fun NeoTextButtonToggle(
text: String,
modifier: Modifier = Modifier,
height: Dp = 40.dp,
paddingH: Dp = 20.dp
) {
var pressed by remember { mutableStateOf(false) }
NeoTextButton(
text = text,
onClick = { pressed = !pressed },
modifier = modifier,
pressed = pressed,
paddingH = paddingH,
height = height
)
}
@Composable
fun NeoToggleChip(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
NeoTextButton(
text = label,
onClick = { onCheckedChange(!checked) },
modifier = modifier,
pressed = checked
)
}
@Composable
fun NeoRadioButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 26.dp
) {
val shape = CircleShape
Box(
modifier = modifier
.requiredSize(size)
.then(
if (!selected) {
Modifier
.neoRaised(shape)
.clip(shape)
.background(NeoBg)
} else {
Modifier
.clip(shape)
.background(NeoBg)
.neoInset(shape)
}
)
.clickable(remember { MutableInteractionSource() }, indication = null) { onClick() },
contentAlignment = Alignment.Center
) {
if (selected) {
Box(
Modifier
.size(size * 0.45f)
.clip(shape)
.background(NeoIcon.copy(alpha = 0.9f))
)
}
}
}
/* -------------------------------------------------------
* Linear Progress (determinate & indeterminate)
* ---------------------------------------------------- */
@Composable
fun NeoProgressBar(
progress: Float, // 0f..1f
modifier: Modifier = Modifier,
height: Dp = 14.dp
) {
val shape = RoundedCornerShape(percent = 50)
Box(
modifier = modifier
.height(height)
.clip(shape)
.background(NeoBg)
.innerShadow(
shape,
Shadow(radius = 5.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.innerShadow(
shape,
Shadow(
radius = 2.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.40f)
)
),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.fillMaxWidth(progress.coerceIn(0f, 1f))
.fillMaxHeight()
.dropShadow(
shape,
Shadow(
radius = 2.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.30f)
)
)
.dropShadow(
shape,
Shadow(radius = 4.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.dropShadow(
shape,
Shadow(
radius = 3.dp,
offset = DpOffset(2.dp, 3.dp),
color = NeoDark.copy(alpha = 0.10f)
)
)
.clip(shape)
.background(NeoBg)
)
}
}
@Composable
fun NeoProgressBarIndeterminate(
modifier: Modifier = Modifier,
height: Dp = 14.dp,
minChunk: Float = 0.18f,
maxChunk: Float = 0.45f,
) {
val shape = RoundedCornerShape(percent = 50)
Box(
modifier = modifier
.height(height)
.clip(shape)
.background(NeoBg)
.innerShadow(
shape,
Shadow(radius = 5.dp, offset = DpOffset(-5.dp, -5.dp), color = NeoLight)
)
.innerShadow(
shape,
Shadow(
radius = 2.dp,
offset = DpOffset(5.dp, 5.dp),
color = NeoDark.copy(alpha = 0.40f)
)
),
contentAlignment = Alignment.CenterStart
) {
// animate head/span
val t = rememberInfiniteTransition(label = "pb")
val head by t.animateFloat(
initialValue = -maxChunk, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1400, easing = FastOutSlowInEasing)),
label = "head"
)
val span by t.animateFloat(
initialValue = minChunk, targetValue = maxChunk,
animationSpec = infiniteRepeatable(
tween(1400, easing = FastOutSlowInEasing),
RepeatMode.Reverse
),
label = "span"
)
val start = (head - span).coerceIn(0f, 1f)
val end = (head + span).coerceIn(0f, 1f)
val frac = (end - start).coerceAtLeast(0f)
BoxWithConstraints(Modifier.fillMaxSize()) {
val startPad = with(LocalDensity.current) { (start * constraints.maxWidth).toDp() }
Box(
Modifier
.fillMaxHeight()
.fillMaxWidth(frac)
.padding(start = startPad)
.clip(shape)
.background(NeoBg)
.innerShadow(
shape,
Shadow(
radius = 3.dp,
offset = DpOffset(1.dp, 1.dp),
color = NeoDark.copy(alpha = 0.22f)
)
)
)
}
}
}
/* -------------------------------------------------------
* Card (toggle: raised ↔ inset)
* ---------------------------------------------------- */
@Composable
fun NeoCardToggle(
modifier: Modifier = Modifier,
corner: Dp = 12.dp,
content: @Composable () -> Unit
) {
var pressed by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(corner)
Box(
modifier = modifier
.then(
if (!pressed) {
Modifier
.neoRaised(shape)
.clip(shape)
.background(NeoBg)
} else {
Modifier
.clip(shape)
.background(NeoBg)
.neoInset(shape)
}
)
.clickable(remember { MutableInteractionSource() }, indication = null) {
pressed = !pressed
}
) {
content()
}
}
/* -------------------------------------------------------
* Showcase (balanced layout, no section titles)
* ---------------------------------------------------- */
@Preview(showBackground = true, backgroundColor = 0xFFE9EDF6)
@Composable
fun NeumorphicExamplePreview() {
NeumorphicExample()
}
@Composable
fun NeumorphicExample(
modifier: Modifier = Modifier,
icons: List<ImageVector> = listOf(
Icons.Default.Person,
Icons.Default.Favorite,
Icons.Default.Star,
Icons.Default.Search
)
) {
var switchChecked by remember { mutableStateOf(false) }
var sliderValue by remember { mutableStateOf(0.45f) }
var checkboxChecked by remember { mutableStateOf(true) }
var chipChecked by remember { mutableStateOf(false) }
var radioSelected by remember { mutableStateOf(0) }
Box(
modifier = modifier
.fillMaxSize()
.background(NeoBg)
) {
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.92f)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Row 1 – small controls cluster
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
NeoCheckbox(
checked = checkboxChecked,
onCheckedChange = { checkboxChecked = it },
size = 28.dp
)
NeoToggleChip(
label = if (chipChecked) "On" else "Off",
checked = chipChecked,
onCheckedChange = { chipChecked = it })
NeoSwitch(
checked = switchChecked,
onCheckedChange = { switchChecked = it },
width = 72.dp,
height = 40.dp
)
}
// Row 2 – radio group
Row(
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalAlignment = Alignment.CenterVertically
) {
listOf(0, 1, 2).forEach { idx ->
NeoRadioButton(
selected = radioSelected == idx,
onClick = { radioSelected = idx })
}
}
// Row 3 – progress bars (indeterminate then determinate)
NeoProgressBarIndeterminate(modifier = Modifier.fillMaxWidth())
NeoProgressBar(progress = sliderValue, modifier = Modifier.fillMaxWidth())
// Row 4 – slider
NeoSlider(
value = sliderValue,
onValueChange = { sliderValue = it },
modifier = Modifier.fillMaxWidth(),
height = 48.dp,
thumbSize = 28.dp,
horizontalPadding = 16.dp
)
// Row 5 – button
NeoTextButtonToggle(text = "Button")
// Row 6 – a few icon chips
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
icons.forEach { icon ->
NeoCircleIconToggle(icon = icon, contentDescription = null, size = 56.dp)
}
}
// Row 7 – square card (raised → inset on tap)
NeoCardToggle(
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
corner = 0.dp
) {
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Neumorphic card", color = NeoIcon)
NeoToggleChip(
label = "Chip",
checked = chipChecked,
onCheckedChange = { chipChecked = it })
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment