Last active
October 15, 2025 16:34
-
-
Save Kyriakos-Georgiopoulos/1382cacdd6057df70a7d59fbd6f9752e to your computer and use it in GitHub Desktop.
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
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