Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active October 15, 2025 16:34
Show Gist options
  • Save Kyriakos-Georgiopoulos/1382cacdd6057df70a7d59fbd6f9752e to your computer and use it in GitHub Desktop.
Save Kyriakos-Georgiopoulos/1382cacdd6057df70a7d59fbd6f9752e to your computer and use it in GitHub Desktop.
import android.app.Activity
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.keyframes
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@Composable
fun ThemeWipeScreen() {
val scope = rememberCoroutineScope()
val fraction = remember { Animatable(0f) } // 0 = light, 1 = dark
var currentTarget by remember { mutableStateOf<Float?>(null) }
var job by remember { mutableStateOf<Job?>(null) }
var pausedByPress by remember { mutableStateOf(false) }
fun animateToTarget(target: Float) {
currentTarget = target
job?.cancel()
job = scope.launch {
val start = fraction.value
fraction.animateTo(
targetValue = target,
animationSpec = keyframes {
durationMillis = 850
(start + (target - start) * 0.90f) at 520 with CubicBezierEasing(
0.2f,
0.8f,
0.2f,
1.0f
)
(target + if (target > start) 0.03f else -0.03f) at 700
target at 850 with FastOutSlowInEasing
}
)
currentTarget = null
}
}
val fabInteractions = remember { MutableInteractionSource() }
LaunchedEffect(fabInteractions) {
fabInteractions.interactions.collect { interaction: Interaction ->
when (interaction) {
is PressInteraction.Press -> {
if (fraction.isRunning) {
pausedByPress = true
fraction.stop() // pause at current value
}
}
is PressInteraction.Release, is PressInteraction.Cancel -> {
if (pausedByPress) {
pausedByPress = false
val tgt = currentTarget ?: if (fraction.value < 0.5f) 1f else 0f
animateToTarget(tgt) // resume
}
}
}
}
}
val lightScheme = lightColorScheme()
val darkScheme = darkColorScheme()
Box(Modifier.fillMaxSize()) {
// Light layer
ThemePreview(light = true) { DemoContent() }
// Dark layer (left) clipped by fraction
Box(
Modifier
.matchParentSize()
.clipToFraction(fraction.value)
) {
ThemePreview(light = false) { DemoContent() }
}
HardSplitStatusBar(
fractionDark = fraction.value,
leftBg = darkScheme.background,
rightBg = lightScheme.background,
iconsBiasRight = true
)
ExtendedFloatingActionButton(
onClick = {
if (pausedByPress || fraction.isRunning) return@ExtendedFloatingActionButton
animateToTarget(if (fraction.value < 0.5f) 1f else 0f)
},
interactionSource = fabInteractions, // << hook presses
icon = {
Icon(
if (fraction.value >= 0.5f) Icons.Filled.LightMode else Icons.Filled.DarkMode,
null
)
},
text = { Text(if (fraction.value >= 0.5f) "Light mode" else "Dark mode") },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(WindowInsets.navigationBars.asPaddingValues())
.padding(16.dp)
)
}
}
@Composable
fun BoxScope.HardSplitStatusBar(
fractionDark: Float, // 0f = all light, 1f = all dark (dark grows from LEFT)
leftBg: Color, // LEFT side bg (your DARK background)
rightBg: Color, // RIGHT side bg (your LIGHT background)
iconsBiasRight: Boolean = true, // choose icon mode by the right side (battery area)
lowThreshold: Float = 0.40f,
highThreshold: Float = 0.60f,
) {
val split = fractionDark.coerceIn(0f, 1f)
val view = LocalView.current
// 1) Icon mode with hysteresis (still only 1 mode is allowed by Android)
var useDarkIcons by remember { mutableStateOf(false) } // true = dark icons on light bg
LaunchedEffect(split, iconsBiasRight) {
val metric = if (iconsBiasRight) (1f - split) else split
when {
metric > highThreshold -> useDarkIcons = true
metric < lowThreshold -> useDarkIcons = false
// else keep previous
}
}
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = useDarkIcons
}
// 2) Paint two OPAQUE halves snapped to pixel boundaries (no gradient, no alpha)
val barHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
Modifier
.fillMaxWidth()
.height(barHeight)
.align(Alignment.TopCenter)
.drawWithContent {
drawContent()
val w = size.width
// snap seam to nearest pixel to avoid sub-pixel AA glow
val seamX = kotlin.math.round(w * split)
if (seamX > 0f) {
drawRect(
color = leftBg,
topLeft = Offset(0f, 0f),
size = Size(seamX, size.height)
)
}
if (seamX < w) {
drawRect(
color = rightBg,
topLeft = Offset(seamX, 0f),
size = Size(w - seamX, size.height)
)
}
}
)
}
private fun Modifier.clipToFraction(fraction: Float) = drawWithContent {
val w = size.width * fraction.coerceIn(0f, 1f)
clipRect(left = 0f, top = 0f, right = w, bottom = size.height) {
[email protected]()
}
}
@Composable
private fun ThemePreview(light: Boolean, content: @Composable () -> Unit) {
val scheme = if (light) lightColorScheme() else darkColorScheme()
MaterialTheme(
colorScheme = scheme,
typography = Typography(),
content = content
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment