Created
October 31, 2025 15:44
-
-
Save Kyriakos-Georgiopoulos/e4f0ea887c6b20758cd760d56849266a 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
| /* | |
| * 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. | |
| */ | |
| @file:Suppress("MagicNumber") | |
| import androidx.compose.animation.animateColorAsState | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.CubicBezierEasing | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.clickable | |
| 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.Spacer | |
| 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.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.outlined.FavoriteBorder | |
| import androidx.compose.material.icons.outlined.NotificationsNone | |
| import androidx.compose.material.icons.outlined.PersonOutline | |
| import androidx.compose.material.icons.outlined.Place | |
| import androidx.compose.material.icons.outlined.Settings | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Scaffold | |
| import androidx.compose.material3.Surface | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableIntStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.graphics.vector.ImageVector | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.dp | |
| import kotlin.math.PI | |
| import kotlin.math.abs | |
| import kotlin.math.max | |
| import kotlin.math.min | |
| import kotlin.math.pow | |
| import kotlin.math.sin | |
| import androidx.compose.ui.graphics.lerp as colorLerp | |
| private data class ThemeSpec(val bg: Color, val bgPill: Color, val bubble: Color) | |
| private val Themes = listOf( | |
| ThemeSpec(Color(0xFF0E1A2B), Color(0xFF22406C), Color(0xFF4FC3F7)), | |
| ThemeSpec(Color(0xFF1A1423), Color(0xFF3D2C5F), Color(0xFFB388FF)), | |
| ThemeSpec(Color(0xFF0D1F1E), Color(0xFF1F4B48), Color(0xFF64FFDA)), | |
| ThemeSpec(Color(0xFF24160B), Color(0xFF5C3415), Color(0xFFFFAB40)), | |
| ThemeSpec(Color(0xFF101A12), Color(0xFF295233), Color(0xFF81C784)) | |
| ) | |
| private val BarWhite = Color(0xFFFFFFFF) | |
| private val IconGray = Color(0xFF2C2C2C) | |
| private const val HANDLE_BASE = 0.44f | |
| private val ARC_BASE_DP = 22.dp | |
| private val WAVE_MAX_DP = 6.dp | |
| private const val MIN_DUR = 1100 | |
| private const val MAX_DUR = 1900 | |
| private val PATH_EASING = CubicBezierEasing(0.15f, 0.00f, 0.00f, 1.00f) | |
| private const val SRC_STRETCH_END = 0.35f | |
| private const val SRC_CLOSE_END = 0.48f | |
| private const val SOURCE_OVERSHOOT = 0.16f | |
| private const val LAND_START = 0.70f | |
| private const val LAND_END = 1.00f | |
| private const val DEST_POP = 0.16f | |
| private val ICON_CENTER_BIAS_DP = (-1.5).dp | |
| @Composable | |
| private fun PillsBackground( | |
| modifier: Modifier = Modifier, | |
| bgColor: Color, | |
| pillColor: Color | |
| ) { | |
| Canvas(modifier.fillMaxSize()) { | |
| drawRect(bgColor) | |
| fun pill(x: Float, y: Float, w: Float, h: Float, radius: Float, angle: Float, alpha: Float) { | |
| rotate(degrees = angle, pivot = Offset(x + w / 2f, y + h / 2f)) { | |
| drawRoundRect( | |
| color = pillColor.copy(alpha = alpha), | |
| topLeft = Offset(x, y), | |
| size = androidx.compose.ui.geometry.Size(w, h), | |
| cornerRadius = CornerRadius(radius, radius) | |
| ) | |
| } | |
| } | |
| val W = size.width | |
| val H = size.height | |
| val r = min(W, H) | |
| pill(W * 0.15f, H * 0.15f, r * 0.55f, r * 0.10f, r * 0.05f, -18f, 0.30f) | |
| pill(W * 0.55f, H * 0.10f, r * 0.45f, r * 0.08f, r * 0.04f, 24f, 0.22f) | |
| pill(W * 0.10f, H * 0.60f, r * 0.60f, r * 0.11f, r * 0.06f, 14f, 0.18f) | |
| pill(W * 0.50f, H * 0.70f, r * 0.40f, r * 0.09f, r * 0.05f, -28f, 0.15f) | |
| } | |
| } | |
| /** | |
| * Concave notch bottom bar with a traveling "liquid" notch and bubble. | |
| * | |
| * This composable draws a bottom bar whose top edge has only the top corners rounded and a | |
| * concave cut-out (the notch) that moves to the selected item. A colored circular "bubble" | |
| * rides in that notch and performs a short arc (slingshot) toward the destination item. | |
| * Icons vertically follow the bubble (lift) and cross-fade their tint. | |
| * | |
| * Animation details: | |
| * - Duration automatically scales with the number of tabs crossed (near = faster, far = slower). | |
| * - The notch widens/deepens on the active (destination) slot on contact, producing a small | |
| * "pop" effect; the source notch closes smoothly. | |
| * - A subtle surface wave runs along the top edge, centered on the bubble. | |
| * | |
| * Layout details: | |
| * - Only the bar's top corners are rounded; the bottom edge is flat for seamless insets. | |
| * - `barTopFraction` controls how much of the total height is above the white bar area. | |
| * - `itemBoxWidth` defines each icon slot's width for spacing and touch target alignment. | |
| * | |
| * @param items Icons to display, in order, one per tab. | |
| * @param selectedIndex The currently selected tab index; triggers the transition when changed. | |
| * @param onItemSelected Callback invoked when a tab is tapped with the index of that tab. | |
| * @param modifier Optional [Modifier] for this bar. | |
| * @param barHeight Total height of the component (background + bar + notch area). | |
| * @param cornerRadius Radius for the bar's top-left/top-right corners. | |
| * @param bubbleSize Diameter of the colored bubble that rides in the notch. | |
| * @param notchPadding Extra horizontal padding around the bubble that shapes the notch. | |
| * @param barTopFraction Fraction of [barHeight] from the top to the white bar top line | |
| * (0f..1f). The remaining height becomes the white bar section. | |
| * @param itemBoxWidth Width reserved for each icon cell; affects spacing and hit area. | |
| * @param edgeTightenDp Reduces side insets so end items sit closer to the corners. | |
| * @param bubbleColor Base color of the bubble; can be animated by the caller. | |
| */ | |
| @Composable | |
| fun ConcaveNotchBottomBar( | |
| items: List<ImageVector>, | |
| selectedIndex: Int, | |
| onItemSelected: (Int) -> Unit, | |
| modifier: Modifier = Modifier, | |
| barHeight: Dp = 116.dp, | |
| cornerRadius: Dp = 18.dp, | |
| bubbleSize: Dp = 46.dp, | |
| notchPadding: Dp = 12.dp, | |
| barTopFraction: Float = 0.45f, | |
| itemBoxWidth: Dp = 32.dp, | |
| edgeTightenDp: Dp = 12.dp, | |
| bubbleColor: Color = Color(0xFF4FC3F7) | |
| ) { | |
| var fromIndex by remember { mutableIntStateOf(selectedIndex) } | |
| var toIndex by remember { mutableIntStateOf(selectedIndex) } | |
| val tAnim = remember { Animatable(1f) } | |
| LaunchedEffect(selectedIndex, items.size) { | |
| if (selectedIndex == toIndex) return@LaunchedEffect | |
| fromIndex = toIndex | |
| toIndex = selectedIndex | |
| val steps = abs(toIndex - fromIndex) | |
| val maxSteps = max(1, items.size - 1) | |
| val frac = steps.toFloat() / maxSteps | |
| val dur = lerpInt(MIN_DUR, MAX_DUR, frac) | |
| tAnim.snapTo(0f) | |
| tAnim.animateTo(1f, tween(dur)) | |
| } | |
| val density = LocalDensity.current | |
| val barTopDp = barHeight * barTopFraction | |
| val whiteHeightDp = barHeight * (1f - barTopFraction) | |
| Surface(color = Color.Transparent, modifier = modifier.fillMaxWidth()) { | |
| BoxWithConstraints(Modifier.fillMaxWidth()) { | |
| val w = with(density) { maxWidth.toPx() } | |
| val h = with(density) { barHeight.toPx() } | |
| val r = with(density) { cornerRadius.toPx() } | |
| val barTop = h * barTopFraction | |
| val whiteH = h - barTop | |
| val bubbleR = with(density) { bubbleSize.toPx() } / 2f | |
| val pad = with(density) { notchPadding.toPx() } | |
| val notchHalfW = bubbleR * 1.72f + pad | |
| val notchDepth = min(bubbleR + pad * 1.35f, (h - barTop) * 0.92f) | |
| val hardInset = r + notchHalfW | |
| val maxPull = max(0f, hardInset - (notchHalfW + with(density) { 4.dp.toPx() })) | |
| val pull = min(with(density) { edgeTightenDp.toPx() }, maxPull) | |
| val inset = hardInset - pull | |
| val itemW = with(density) { itemBoxWidth.toPx() } | |
| val cStart = inset + itemW / 2f | |
| val cEnd = (w - inset) - itemW / 2f | |
| val step = if (items.size > 1) (cEnd - cStart) / (items.size - 1) else 0f | |
| fun cxFor(i: Int) = cStart + i * step | |
| val t = PATH_EASING.transform(tAnim.value) | |
| val ts = slingTimeSmooth(t) | |
| val fromCx = cxFor(fromIndex) | |
| val toCx = cxFor(toIndex) | |
| val dx = toCx - fromCx | |
| val handle = HANDLE_BASE * (1f + 0.35f * (abs(dx) / (step * max(1, items.size - 1)))) | |
| val c1 = fromCx + dx * handle | |
| val c2 = toCx - dx * handle | |
| val bubbleCx = cubicBezier1D(fromCx, c1, c2, toCx, ts) | |
| val arcPx = with(density) { ARC_BASE_DP.toPx() } * (1f + 0.25f * (abs(dx) / max(step, 1f))) | |
| val jumpArc = -arcPx * sin(PI * ts).toFloat() | |
| val valleyY = barTop + notchDepth | |
| val rawBubbleCy = valleyY - (bubbleR + pad) + jumpArc | |
| val minBubbleCy = bubbleR + with(density) { 2.dp.toPx() } | |
| val bubbleCy = max(minBubbleCy, rawBubbleCy) | |
| val iconRestCY = barTop + whiteH / 2f | |
| val iconBiasPx = with(density) { ICON_CENTER_BIAS_DP.toPx() } | |
| val iconLiftPx = (bubbleCy - iconRestCY) + iconBiasPx | |
| val horizontalInsetDp = with(density) { inset.toDp() } | |
| val closeGate = 1f - gate(ts, 0f, SRC_CLOSE_END) | |
| val stretchBell = softBell(ts, 0f, SRC_STRETCH_END) | |
| val srcStrength = closeGate | |
| val srcOvershoot = 1f + SOURCE_OVERSHOOT * stretchBell | |
| val slotsAway = abs((bubbleCx - toCx) / (step.takeIf { it > 0f } ?: 1f)) | |
| val prox = smoothstepPow(1f - slotsAway.coerceIn(0f, 1.1f), 3.0f) | |
| val contactRaw = 1f - (abs(jumpArc) / max(arcPx, 1e-3f)) | |
| val contactGate = gate(ts, LAND_START, LAND_END) | |
| val contact = (contactRaw.coerceIn(0f, 1f) * contactGate).coerceIn(0f, 1f) | |
| val destStrength = (prox * contact).coerceIn(0f, 1f) | |
| val contactPop = if (contact > 0f) destStrength.pow(0.6f) else 0f | |
| val destWidthMul = 1f + DEST_POP * contactPop | |
| val destDepthMul = 1f + DEST_POP * contactPop | |
| val notches = buildList { | |
| if (srcStrength > 0.001f) add( | |
| Notch( | |
| cx = fromCx, | |
| halfWidth = notchHalfW * srcStrength * srcOvershoot, | |
| depth = notchDepth * srcStrength * srcOvershoot | |
| ) | |
| ) | |
| if (destStrength > 0.001f) add( | |
| Notch( | |
| cx = toCx, | |
| halfWidth = notchHalfW * destStrength * destWidthMul, | |
| depth = notchDepth * destStrength * destDepthMul | |
| ) | |
| ) | |
| } | |
| val wave = waveAmplitude(ts, with(density) { WAVE_MAX_DP.toPx() }) + (2f * contactPop) | |
| val bubbleCyAtFromStart = valleyY - (bubbleR + pad) | |
| val prevLiftStartPx = (bubbleCyAtFromStart - iconRestCY) + iconBiasPx | |
| val prevReturnLiftPx = prevLiftStartPx * closeGate | |
| val prevTintBlend = gate(ts, 0f, SRC_CLOSE_END) | |
| val prevTintColor = colorLerp(Color.White, IconGray, prevTintBlend) | |
| Box(Modifier.fillMaxWidth()) { | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(barHeight) | |
| ) { | |
| drawTopRoundedBarWithNotches( | |
| width = w, | |
| height = h, | |
| top = barTop, | |
| topCornerRadius = r, | |
| notches = notches, | |
| waveCenterX = bubbleCx, | |
| waveAmplitude = wave, | |
| color = BarWhite | |
| ) | |
| drawCircle(color = bubbleColor, radius = bubbleR, center = Offset(bubbleCx, bubbleCy)) | |
| } | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(barHeight) | |
| ) { | |
| Spacer(Modifier.height(barTopDp)) | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = horizontalInsetDp) | |
| .height(whiteHeightDp), | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| items.forEachIndexed { index, icon -> | |
| val isTo = index == toIndex | |
| val isFrom = index == fromIndex && fromIndex != toIndex | |
| val lift = when { | |
| isTo -> iconLiftPx | |
| isFrom -> prevReturnLiftPx | |
| else -> 0f | |
| } | |
| val tint = when { | |
| isTo -> Color.White | |
| isFrom -> prevTintColor | |
| else -> IconGray | |
| } | |
| Box( | |
| modifier = Modifier | |
| .width(itemBoxWidth) | |
| .fillMaxHeight() | |
| .clickable( | |
| interactionSource = remember { MutableInteractionSource() }, | |
| indication = null | |
| ) { onItemSelected(index) }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| imageVector = icon, | |
| contentDescription = null, | |
| tint = tint, | |
| modifier = Modifier | |
| .graphicsLayer { translationY = lift } | |
| .size(if (isTo) 28.dp else 26.dp) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private fun DrawScope.drawTopRoundedBarWithNotches( | |
| width: Float, | |
| height: Float, | |
| top: Float, | |
| topCornerRadius: Float, | |
| notches: List<Notch>, | |
| waveCenterX: Float, | |
| waveAmplitude: Float, | |
| color: Color | |
| ) { | |
| val left = 0f | |
| val right = width | |
| val bottom = height | |
| val ns = notches.sortedBy { it.cx } | |
| val eps = 0.8f | |
| val nearThreshold = topCornerRadius + 2f | |
| val waveWidth = width * 0.26f | |
| val ctrlY = top - waveAmplitude * 0.6f | |
| val rightCtrlX = min(right - topCornerRadius * 1.1f, waveCenterX + waveWidth * 0.55f) | |
| val leftCtrlX = max(left + topCornerRadius * 1.1f, waveCenterX - waveWidth * 0.55f) | |
| val leftmost = ns.firstOrNull() | |
| val rightmost = ns.lastOrNull() | |
| val leftNearCorner = leftmost != null && (leftmost.cx - leftmost.halfWidth) <= (left + topCornerRadius + nearThreshold) | |
| val rightNearCorner = rightmost != null && (rightmost.cx + rightmost.halfWidth) >= (right - topCornerRadius - nearThreshold) | |
| val p = Path().apply { | |
| moveTo(left, bottom) | |
| lineTo(right, bottom) | |
| lineTo(right, top + topCornerRadius) | |
| quadraticTo(right, top, right - topCornerRadius, top) | |
| fun Path.roundedU(cx: Float, halfW: Float, depth: Float) { | |
| val k = 0.66f | |
| val startX = cx - halfW - eps | |
| val endX = cx + halfW + eps | |
| val valleyX = cx | |
| val valleyY = top + depth | |
| cubicTo(endX - halfW * (1f - k), top, valleyX + halfW * k, valleyY, valleyX, valleyY) | |
| cubicTo(valleyX - halfW * k, valleyY, startX + halfW * (1f - k), top, startX, top) | |
| } | |
| for (i in ns.indices.reversed()) { | |
| val n = ns[i] | |
| val endX = n.cx + n.halfWidth + eps | |
| if (i == ns.lastIndex && rightNearCorner) { | |
| lineTo(endX, top) | |
| } else { | |
| quadraticTo(rightCtrlX, ctrlY, endX, top) | |
| } | |
| roundedU(n.cx, n.halfWidth, n.depth) | |
| } | |
| if (leftNearCorner) { | |
| lineTo(left + topCornerRadius, top) | |
| } else { | |
| quadraticTo(leftCtrlX, ctrlY, left + topCornerRadius, top) | |
| } | |
| quadraticTo(left, top, left, top + topCornerRadius) | |
| lineTo(left, bottom) | |
| close() | |
| } | |
| drawPath(path = p, color = color) | |
| } | |
| private data class Notch(val cx: Float, val halfWidth: Float, val depth: Float) | |
| private fun cubicBezier1D(p0: Float, p1: Float, p2: Float, p3: Float, t: Float): Float { | |
| val u = 1f - t | |
| return u * u * u * p0 + 3f * u * u * t * p1 + 3f * u * t * t * p2 + t * t * t * p3 | |
| } | |
| private fun slingTimeSmooth(t: Float): Float { | |
| val fastOut = CubicBezierEasing(0.05f, 0.00f, 0.20f, 1.00f).transform(t) | |
| val blend = if (t < 0.5f) 0.85f else 0.55f | |
| val base = (fastOut * blend + t * (1f - blend)).coerceIn(0f, 1f) | |
| val cushion = smoothstep((base / 0.25f).coerceIn(0f, 1f)) | |
| return (0.12f * cushion + 0.88f * base).coerceIn(0f, 1f) | |
| } | |
| private fun softBell(t: Float, a: Float, b: Float): Float { | |
| if (t <= a || t >= b) return 0f | |
| val x = ((t - a) / (b - a)).coerceIn(0f, 1f) | |
| val s = smoothstep(x) | |
| val mid = if (s <= 0.5f) s * 2f else (1f - s) * 2f | |
| return smoothstep(mid) | |
| } | |
| private fun gate(t: Float, a: Float, b: Float): Float { | |
| if (t <= a) return 0f | |
| if (t >= b) return 1f | |
| val x = ((t - a) / (b - a)).coerceIn(0f, 1f) | |
| return smoothstep(x) | |
| } | |
| private fun smoothstep(x: Float): Float { | |
| val t = x.coerceIn(0f, 1f) | |
| return t * t * t * (t * (t * 6 - 15) + 10) | |
| } | |
| private fun smoothstepPow(x: Float, power: Float): Float { | |
| val s = smoothstep(x) | |
| return s.toDouble().pow(power.toDouble()).toFloat() | |
| } | |
| private fun waveAmplitude(t: Float, maxAmp: Float): Float { | |
| val takeoff = if (t <= 0.35f) sin(t / 0.35f * PI).toFloat() else 0f | |
| val land = if (t >= 0.55f) sin((t - 0.55f) / 0.45f * PI).toFloat() else 0f | |
| return maxAmp * max(takeoff, land) | |
| } | |
| private fun lerpInt(a: Int, b: Int, t: Float): Int = (a + (b - a) * t).toInt() | |
| @Composable | |
| fun ConcaveNotchBottomBarDemo() { | |
| var selected by remember { mutableIntStateOf(2) } | |
| val icons = listOf( | |
| Icons.Outlined.Settings, | |
| Icons.Outlined.FavoriteBorder, | |
| Icons.Outlined.Place, | |
| Icons.Outlined.NotificationsNone, | |
| Icons.Outlined.PersonOutline | |
| ) | |
| val theme = Themes[selected % Themes.size] | |
| val bgColor by animateColorAsState(targetValue = theme.bg, animationSpec = tween(500), label = "bg") | |
| val pillColor by animateColorAsState(targetValue = theme.bgPill, animationSpec = tween(500), label = "pill") | |
| val bubbleColor by animateColorAsState(targetValue = theme.bubble, animationSpec = tween(500), label = "bubble") | |
| MaterialTheme { | |
| Box(Modifier.fillMaxSize()) { | |
| PillsBackground(modifier = Modifier.fillMaxSize(), bgColor = bgColor, pillColor = pillColor) | |
| Scaffold( | |
| containerColor = Color.Transparent, | |
| bottomBar = { | |
| ConcaveNotchBottomBar( | |
| items = icons, | |
| selectedIndex = selected, | |
| onItemSelected = { selected = it }, | |
| barHeight = 116.dp, | |
| cornerRadius = 20.dp, | |
| bubbleSize = 46.dp, | |
| notchPadding = 12.dp, | |
| barTopFraction = 0.45f, | |
| itemBoxWidth = 32.dp, | |
| edgeTightenDp = 16.dp, | |
| bubbleColor = bubbleColor | |
| ) | |
| } | |
| ) { inner -> | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(inner), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text("Content", color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center) | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment