diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 1580500..31bdaef 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -11,6 +11,7 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index cfc0c81..b46d6f6 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,3 +1,4 @@ + @@ -88,16 +89,4 @@ - - - \ No newline at end of file diff --git a/README.md b/README.md index 48c51d8..630f445 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,44 @@ Download the app [here](./app/release/app-release.apk). You mustn't use the shaders in the project for commercial purposes, they are only for educational purposes. +## Library + +The library is under construction, use on your own risk. + +It doesn't support nested glass effects for now. + +### Examples + +```kotlin +val providerState = rememberLiquidGlassProviderState() + +CompositionLocalProvider( + LocalLiquidGlassProviderState provides providerState +) { + Box( + Modifier + .liquidGlassProvider(providerState) + ) + + Box( + Modifier + .liquidGlass( + remember { + LiquidGlassStyle( + shape = CornerShape.extraLarge, + blurRadius = 8.dp, + refractionHeight = 24.dp, + refractionAmount = (-64).dp, + whitePoint = 0.25f, + chromaMultiplier = 1.5f + // ... + ) + } + ) + ) +} +``` + ## Glass parameters | Parameter | Availability | Verification | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1244801..800b460 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ android { } dependencies { + implementation(project(":library")) implementation(files("libs/expressa-core-release.aar")) implementation(files("libs/expressa-components-release.aar")) implementation(libs.androidx.core.ktx) diff --git a/app/release/app-release.apk b/app/release/app-release.apk index 67d76bb..c0a7178 100644 Binary files a/app/release/app-release.apk and b/app/release/app-release.apk differ diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm index 14df4e7..759c63c 100644 Binary files a/app/release/baselineProfiles/0/app-release.dm and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm index 0c62357..9c8948c 100644 Binary files a/app/release/baselineProfiles/1/app-release.dm and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 1858e63..0000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/java/com/kyant/glass/MainActivity.kt b/app/src/main/java/com/kyant/glass/MainActivity.kt index 1a5d6c9..fe8cf17 100644 --- a/app/src/main/java/com/kyant/glass/MainActivity.kt +++ b/app/src/main/java/com/kyant/glass/MainActivity.kt @@ -24,7 +24,6 @@ import com.kyant.expressa.ripple.RippleConfiguration import com.kyant.expressa.ripple.ripple import com.kyant.expressa.ui.LocalContentColor import com.kyant.glass.playground.Preview -import com.kyant.glass.playground.PreviewState class MainActivity : ComponentActivity() { @@ -60,8 +59,7 @@ class MainActivity : ComponentActivity() { view.rootView.background = background.toArgb().toDrawable() } - val previewState = remember { PreviewState() } - Preview(previewState) + Preview() } } } diff --git a/app/src/main/java/com/kyant/glass/playground/ConfigurationBottomSheet.kt b/app/src/main/java/com/kyant/glass/playground/ConfigurationBottomSheet.kt index c6023af..ed8ad97 100644 --- a/app/src/main/java/com/kyant/glass/playground/ConfigurationBottomSheet.kt +++ b/app/src/main/java/com/kyant/glass/playground/ConfigurationBottomSheet.kt @@ -9,136 +9,165 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.kyant.expressa.components.button.Button +import com.kyant.expressa.components.button.ButtonColors import com.kyant.expressa.components.iconbutton.IconButton +import com.kyant.expressa.components.iconbutton.IconButtonColors +import com.kyant.expressa.m3.LocalColorScheme import com.kyant.expressa.m3.motion.MotionScheme import com.kyant.expressa.m3.shape.CornerShape import com.kyant.expressa.prelude.* import com.kyant.expressa.ui.Icon import com.kyant.expressa.ui.Text import com.kyant.glass.R +import com.kyant.liquidglass.LiquidGlassStyle +import com.kyant.liquidglass.liquidGlass @Composable fun ConfigurationBottomSheet( state: PreviewState, modifier: Modifier = Modifier ) { - Column( - modifier - .pointerInput(Unit) { - detectTapGestures {} - } - .shadow( - elevation = 8.dp, - shape = CornerShape.extraLarge, - ambientColor = Color.Black.copy(alpha = 0.3f), - spotColor = Color.Black.copy(alpha = 0.3f) - ) - .background(surface) - .padding(16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) + // val providerState = rememberLiquidGlassProviderState() + CompositionLocalProvider( + // LocalLiquidGlassProviderState provides providerState ) { - Row( - modifier - .fillMaxWidth() - .padding(start = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - when (state.configurationMode) { - ConfigurationMode.Colors -> stringResource(R.string.colors) - ConfigurationMode.Advanced -> stringResource(R.string.advanced_settings) - null -> "" - }, - titleMedium, - Modifier.weight(1f) - ) - - IconButton( - { state.configurationMode = null } - ) { - Icon( - painterResource(R.drawable.close_24px), - stringResource(R.string.close) - ) - } - } - + val isDark = LocalColorScheme.current.isDark Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - val retainedConfigurationMode = remember { - Snapshot.withoutReadObservation { state.configurationMode } - } - when (retainedConfigurationMode) { - ConfigurationMode.Colors -> { - SliderChip( - state.blurRadius, - stringResource(R.string.blur_radius), - { Icon(painterResource(R.drawable.lens_blur_24px)) }, - Modifier.fillMaxWidth() - ) - SliderChip( - state.contrast, - stringResource(R.string.contrast), - { Icon(painterResource(R.drawable.contrast_24px)) }, - Modifier.fillMaxWidth() - ) - SliderChip( - state.whitePoint, - stringResource(R.string.white_point), - { Icon(painterResource(R.drawable.wb_sunny_24px)) }, - Modifier.fillMaxWidth() - ) - SliderChip( - state.chromaMultiplier, - stringResource(R.string.chroma_multiplier), - { Icon(painterResource(R.drawable.palette_24px)) }, - Modifier.fillMaxWidth() - ) - } - - ConfigurationMode.Advanced -> { - Button({ state.unsafeMode = !state.unsafeMode }) { - Text( - if (state.unsafeMode) stringResource(R.string.disable_unsafe_mode) - else stringResource(R.string.enable_unsafe_mode), - Modifier.animateContentSize(MotionScheme.fastSpatial()) + modifier + // .liquidGlassProvider(providerState) + .liquidGlass( + remember(isDark) { + LiquidGlassStyle( + shape = CornerShape.extraLarge, + blurRadius = 8.dp, + refractionHeight = 24.dp, + refractionAmount = (-64).dp, + whitePoint = if (isDark) -0.25f else 0.25f, + chromaMultiplier = 1.5f ) } - SliderChip( - state.bleedBlurRadius, - stringResource(R.string.bleed_blur_radius), - { Icon(painterResource(R.drawable.lens_blur_24px)) }, - Modifier.fillMaxWidth() + ) + .background(surface.copy(alpha = 0.6f)) + .pointerInput(Unit) { + detectTapGestures {} + } + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + when (state.configurationMode) { + ConfigurationMode.Colors -> stringResource(R.string.colors) + ConfigurationMode.Advanced -> stringResource(R.string.advanced_settings) + null -> "" + }, + titleMedium, + Modifier.weight(1f) + ) + + IconButton( + { state.configurationMode = null }, + Modifier.liquidGlass(remember { LiquidGlassStyle(CornerShape.full) }), + colors = IconButtonColors.tonal( + containerColor = primaryContainer.copy(alpha = 0.6f), + contentColor = Color.White ) - SliderChip( - state.eccentricFactor, - stringResource(R.string.eccentric_factor), - { Icon(painterResource(R.drawable.all_out_24px)) }, - Modifier.fillMaxWidth() - ) - SliderChip( - state.dispersionHeight, - stringResource(R.string.dispersion_height), - { Icon(painterResource(R.drawable.star_shine_24px)) }, - Modifier.fillMaxWidth() + ) { + Icon( + painterResource(R.drawable.close_24px), + stringResource(R.string.close) ) } + } - null -> {} + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val retainedConfigurationMode = remember { + Snapshot.withoutReadObservation { state.configurationMode } + } + when (retainedConfigurationMode) { + ConfigurationMode.Colors -> { + SliderChip( + state.blurRadius, + stringResource(R.string.blur_radius), + { Icon(painterResource(R.drawable.lens_blur_24px)) }, + Modifier.fillMaxWidth() + ) + SliderChip( + state.contrast, + stringResource(R.string.contrast), + { Icon(painterResource(R.drawable.contrast_24px)) }, + Modifier.fillMaxWidth() + ) + SliderChip( + state.whitePoint, + stringResource(R.string.white_point), + { Icon(painterResource(R.drawable.wb_sunny_24px)) }, + Modifier.fillMaxWidth() + ) + SliderChip( + state.chromaMultiplier, + stringResource(R.string.chroma_multiplier), + { Icon(painterResource(R.drawable.palette_24px)) }, + Modifier.fillMaxWidth() + ) + } + + ConfigurationMode.Advanced -> { + Button( + { state.unsafeMode = !state.unsafeMode }, + Modifier.liquidGlass(remember { LiquidGlassStyle(CornerShape.full) }), + colors = ButtonColors.filled( + containerColor = primary.copy(alpha = 0.6f) + ) + ) { + Text( + if (state.unsafeMode) stringResource(R.string.disable_unsafe_mode) + else stringResource(R.string.enable_unsafe_mode), + Modifier.animateContentSize(MotionScheme.fastSpatial()) + ) + } + SliderChip( + state.bleedBlurRadius, + stringResource(R.string.bleed_blur_radius), + { Icon(painterResource(R.drawable.lens_blur_24px)) }, + Modifier.fillMaxWidth() + ) + SliderChip( + state.eccentricFactor, + stringResource(R.string.eccentric_factor), + { Icon(painterResource(R.drawable.all_out_24px)) }, + Modifier.fillMaxWidth() + ) + SliderChip( + state.dispersionHeight, + stringResource(R.string.dispersion_height), + { Icon(painterResource(R.drawable.star_shine_24px)) }, + Modifier.fillMaxWidth() + ) + } + + null -> {} + } } } } diff --git a/app/src/main/java/com/kyant/glass/playground/Preview.kt b/app/src/main/java/com/kyant/glass/playground/Preview.kt index a24805d..4910ab0 100644 --- a/app/src/main/java/com/kyant/glass/playground/Preview.kt +++ b/app/src/main/java/com/kyant/glass/playground/Preview.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,6 +23,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.drawLayer @@ -42,59 +44,56 @@ import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.util.fastCoerceAtMost import com.kyant.expressa.prelude.* import com.kyant.glass.R +import com.kyant.liquidglass.LocalLiquidGlassProviderState +import com.kyant.liquidglass.liquidGlassProvider +import com.kyant.liquidglass.rememberLiquidGlassProviderState import org.intellij.lang.annotations.Language @Composable -fun Preview(state: PreviewState) { +fun Preview() { + val state = remember { PreviewState() } + val graphicsLayer = rememberGraphicsLayer() val backgroundColor = surfaceContainerLowest val containerSize = LocalWindowInfo.current.containerSize - var rect by remember { mutableStateOf(Rect.Zero) } - val painter = state.imageBitmap?.let { BitmapPainter(it) } ?: painterResource(R.drawable.homescreen) - Box( - Modifier.fillMaxSize() + val providerState = rememberLiquidGlassProviderState() + CompositionLocalProvider( + LocalLiquidGlassProviderState provides providerState ) { - // background - Image( - painter, null, - Modifier - .fillMaxSize() - .drawWithContent { - drawContent() + Box(Modifier.fillMaxSize()) { + // background + Image( + painter, null, + Modifier + .fillMaxSize() + .drawWithContent { + drawContent() - graphicsLayer.record { - drawRect(backgroundColor) - this@drawWithContent.drawContent() + graphicsLayer.record { + drawRect(backgroundColor) + this@drawWithContent.drawContent() + } } - }, - contentScale = ContentScale.Crop - ) + .liquidGlassProvider(providerState), + contentScale = ContentScale.Crop + ) - @Language("AGSL") - val colorShaderUtils = """// This file belongs to Kyant. You must not use it without permission. + @Language("AGSL") + val colorShaderUtils = """// This file belongs to Kyant. You must not use it without permission. const half3 rgbToY = half3(0.2126, 0.7152, 0.0722); float luma(half4 color) { return dot(toLinearSrgb(color.rgb), rgbToY); } - - half4 saturateColor(half4 color, float amount) { - half3 linearSrgb = toLinearSrgb(color.rgb); - float y = dot(linearSrgb, rgbToY); - half3 gray = half3(y); - half3 adjustedLinearSrgb = mix(gray, linearSrgb, amount); - half3 adjustedSrgb = fromLinearSrgb(adjustedLinearSrgb); - return half4(adjustedSrgb, color.a); - } """.trimIndent() - @Language("AGSL") - val refractionShaderUtils = """// This file belongs to Kyant. You must not use it without permission. + @Language("AGSL") + val refractionShaderUtils = """// This file belongs to Kyant. You must not use it without permission. float circleMap(float x) { return 1.0 - sqrt(1.0 - x * x); } @@ -147,24 +146,24 @@ fun Preview(state: PreviewState) { } """.trimIndent() - // glass - Box( - Modifier - .graphicsLayer { // transform and clip - translationX = state.offset.x - translationY = state.offset.y - clip = true - shape = RoundedCornerShape(state.cornerRadius.value) - } - .graphicsLayer { // white point & chroma boost - val contrast = state.contrast.value - val whitePoint = state.whitePoint.value - val chromaMultiplier = state.chromaMultiplier.value + // glass + Box( + Modifier + .graphicsLayer { // transform and clip + translationX = state.offset.x + translationY = state.offset.y + clip = true + shape = RoundedCornerShape(state.cornerRadius.value) + } + .graphicsLayer { // white point & chroma boost + val contrast = state.contrast.value + val whitePoint = state.whitePoint.value + val chromaMultiplier = state.chromaMultiplier.value - if (contrast != 0f || whitePoint != 0f || chromaMultiplier != 1f) { - renderEffect = RenderEffect.createRuntimeShaderEffect( - RuntimeShader( - """// This file belongs to Kyant. You must not use it without permission. + if (contrast != 0f || whitePoint != 0f || chromaMultiplier != 1f) { + renderEffect = RenderEffect.createRuntimeShaderEffect( + RuntimeShader( + """// This file belongs to Kyant. You must not use it without permission. uniform shader image; uniform float contrast; @@ -173,6 +172,15 @@ fun Preview(state: PreviewState) { $colorShaderUtils + half4 saturateColor(half4 color, float amount) { + half3 linearSrgb = toLinearSrgb(color.rgb); + float y = dot(linearSrgb, rgbToY); + half3 gray = half3(y); + half3 adjustedLinearSrgb = mix(gray, linearSrgb, amount); + half3 adjustedSrgb = fromLinearSrgb(adjustedLinearSrgb); + return half4(adjustedSrgb, color.a); + } + half4 main(float2 coord) { half4 color = image.eval(coord); @@ -185,19 +193,19 @@ fun Preview(state: PreviewState) { return color; }""" - ).apply { - setFloatUniform("contrast", contrast) - setFloatUniform("whitePoint", whitePoint) - setFloatUniform("chromaMultiplier", chromaMultiplier) - }, - "image" - ).asComposeRenderEffect() + ).apply { + setFloatUniform("contrast", contrast) + setFloatUniform("whitePoint", whitePoint) + setFloatUniform("chromaMultiplier", chromaMultiplier) + }, + "image" + ).asComposeRenderEffect() + } } - } - .graphicsLayer { // dispersion effect - renderEffect = RenderEffect.createRuntimeShaderEffect( - RuntimeShader( - """// This file belongs to Kyant. You must not use it without permission. + .graphicsLayer { // dispersion effect + renderEffect = RenderEffect.createRuntimeShaderEffect( + RuntimeShader( + """// This file belongs to Kyant. You must not use it without permission. uniform shader image; uniform float2 size; @@ -286,22 +294,22 @@ fun Preview(state: PreviewState) { return color; } }""" - ).apply { - val cornerRadius = - state.cornerRadius.value.toPx() - .fastCoerceAtMost(size.minDimension / 2f) + ).apply { + val cornerRadius = + state.cornerRadius.value.toPx() + .fastCoerceAtMost(size.minDimension / 2f) - setFloatUniform("size", size.width, size.height) - setFloatUniform("cornerRadius", cornerRadius) - setFloatUniform("dispersionHeight", state.dispersionHeight.value.toPx()) - }, - "image" - ).asComposeRenderEffect() - } - .graphicsLayer { // refraction & bleed - val refractionRenderEffect = RenderEffect.createRuntimeShaderEffect( - RuntimeShader( - """// This file belongs to Kyant. You must not use it without permission. + setFloatUniform("size", size.width, size.height) + setFloatUniform("cornerRadius", cornerRadius) + setFloatUniform("dispersionHeight", state.dispersionHeight.value.toPx()) + }, + "image" + ).asComposeRenderEffect() + } + .graphicsLayer { // refraction & bleed + val refractionRenderEffect = RenderEffect.createRuntimeShaderEffect( + RuntimeShader( + """// This file belongs to Kyant. You must not use it without permission. uniform shader image; uniform float2 size; @@ -326,22 +334,22 @@ fun Preview(state: PreviewState) { return color; } }""" - ).apply { - setFloatUniform("size", size.width, size.height) - setFloatUniform("cornerRadius", state.cornerRadius.value.toPx()) + ).apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("cornerRadius", state.cornerRadius.value.toPx()) - setFloatUniform("refractionHeight", state.refractionHeight.value.toPx()) - setFloatUniform("refractionAmount", state.refractionAmount.value.toPx()) - setFloatUniform("eccentricFactor", state.eccentricFactor.value) + setFloatUniform("refractionHeight", state.refractionHeight.value.toPx()) + setFloatUniform("refractionAmount", state.refractionAmount.value.toPx()) + setFloatUniform("eccentricFactor", state.eccentricFactor.value) - setFloatUniform("bleedOpacity", state.bleedOpacity.value) - }, - "image" - ) + setFloatUniform("bleedOpacity", state.bleedOpacity.value) + }, + "image" + ) - val bleedRenderEffect = RenderEffect.createRuntimeShaderEffect( - RuntimeShader( - """// This file belongs to Kyant. You must not use it without permission. + val bleedRenderEffect = RenderEffect.createRuntimeShaderEffect( + RuntimeShader( + """// This file belongs to Kyant. You must not use it without permission. uniform shader image; uniform float2 size; @@ -359,109 +367,110 @@ fun Preview(state: PreviewState) { color.rgb = mix(color.rgb, half3(1.0), 0.5 * circleMap(1.0 - luma)); return color; }""" - ).apply { - setFloatUniform("size", size.width, size.height) - setFloatUniform("cornerRadius", state.cornerRadius.value.toPx()) + ).apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("cornerRadius", state.cornerRadius.value.toPx()) - setFloatUniform("eccentricFactor", state.eccentricFactor.value) - setFloatUniform("bleedAmount", state.bleedAmount.value.toPx()) - }, - "image" - ) - - renderEffect = - if (state.bleedOpacity.value > 0f) { - val bleedBlurRadiusPx = state.bleedBlurRadius.value.toPx() - val blurredBleedRenderEffect = - if (bleedBlurRadiusPx > 0f) { - RenderEffect.createChainEffect( - bleedRenderEffect, - RenderEffect.createBlurEffect( - bleedBlurRadiusPx, - bleedBlurRadiusPx, - Shader.TileMode.CLAMP - ) - ) - } else { - bleedRenderEffect - } - - RenderEffect.createBlendModeEffect( - blurredBleedRenderEffect, - refractionRenderEffect, - BlendMode.SRC_OVER - ).asComposeRenderEffect() - } else { - refractionRenderEffect.asComposeRenderEffect() - } - } - .graphicsLayer { // blur - val blurRadiusPx = state.blurRadius.value.toPx() - if (blurRadiusPx > 0f) { - renderEffect = - RenderEffect.createBlurEffect( - blurRadiusPx, - blurRadiusPx, - Shader.TileMode.DECAL - ).asComposeRenderEffect() - } - } - .drawBehind { - translate(-rect.left, -rect.top) { - drawLayer(graphicsLayer) - } - } - .onGloballyPositioned { layoutCoordinates -> - rect = layoutCoordinates.boundsInParent() - } - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - state.offset += pan - state.size = DpSize( - (state.size.width * zoom).coerceIn( - state.minSize.width, - containerSize.width.toDp() - ), - (state.size.height * zoom).coerceIn( - state.minSize.height, - containerSize.height.toDp() - ) + setFloatUniform("eccentricFactor", state.eccentricFactor.value) + setFloatUniform("bleedAmount", state.bleedAmount.value.toPx()) + }, + "image" ) - } - } - .layout { measurable, constraints -> - val size = state.size.toSize().roundToIntSize() - val width = size.width.fastCoerceAtMost(constraints.maxWidth) - val height = size.height.fastCoerceAtMost(constraints.maxHeight) - val placeable = measurable.measure(Constraints.fixed(width, height)) - layout(width, height) { - placeable.place(0, 0) - } - } - .align(Alignment.Center) - ) + renderEffect = + if (state.bleedOpacity.value > 0f) { + val bleedBlurRadiusPx = state.bleedBlurRadius.value.toPx() + val blurredBleedRenderEffect = + if (bleedBlurRadiusPx > 0f) { + RenderEffect.createChainEffect( + bleedRenderEffect, + RenderEffect.createBlurEffect( + bleedBlurRadiusPx, + bleedBlurRadiusPx, + Shader.TileMode.CLAMP + ) + ) + } else { + bleedRenderEffect + } - BlurredAnimatedContent({ state.displayControls }) { visible -> - if (visible) { + RenderEffect.createBlendModeEffect( + blurredBleedRenderEffect, + refractionRenderEffect, + BlendMode.SRC_OVER + ).asComposeRenderEffect() + } else { + refractionRenderEffect.asComposeRenderEffect() + } + } + .graphicsLayer { // blur + val blurRadiusPx = state.blurRadius.value.toPx() + if (blurRadiusPx > 0f) { + renderEffect = + RenderEffect.createBlurEffect( + blurRadiusPx, + blurRadiusPx, + Shader.TileMode.DECAL + ).asComposeRenderEffect() + } + } + .drawBehind { + clipRect(0f, 0f, rect.width, rect.height) { + translate(-rect.left, -rect.top) { + drawLayer(graphicsLayer) + } + } + } + .onGloballyPositioned { layoutCoordinates -> + rect = layoutCoordinates.boundsInParent() + } + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + state.offset += pan + state.size = DpSize( + (state.size.width * zoom).coerceIn( + state.minSize.width, + containerSize.width.toDp() + ), + (state.size.height * zoom).coerceIn( + state.minSize.height, + containerSize.height.toDp() + ) + ) + } + } + .layout { measurable, constraints -> + val size = state.size.toSize().roundToIntSize() + val width = size.width.fastCoerceAtMost(constraints.maxWidth) + val height = size.height.fastCoerceAtMost(constraints.maxHeight) + val placeable = measurable.measure(Constraints.fixed(width, height)) + + layout(width, height) { + placeable.place(0, 0) + } + } + .align(Alignment.Center) + ) + + if (state.displayControls) { PreviewControls( state, { rect } ) } - } - BlurredAnimatedContent( - { state.isInConfigurationMode }, - Modifier - .padding(16.dp) - .safeDrawingPadding() - .align(Alignment.BottomCenter) - ) { isInConfigurationMode -> - if (!isInConfigurationMode) { - ButtonGroup(state) - } else { - ConfigurationBottomSheet(state) + BlurredAnimatedContent( + { state.isInConfigurationMode }, + Modifier + .padding(16.dp) + .safeDrawingPadding() + .align(Alignment.BottomCenter) + ) { isInConfigurationMode -> + if (!isInConfigurationMode) { + PreviewToolbar(state) + } else { + ConfigurationBottomSheet(state) + } } } } diff --git a/app/src/main/java/com/kyant/glass/playground/PreviewControls.kt b/app/src/main/java/com/kyant/glass/playground/PreviewControls.kt index d3a3caa..b7f4edf 100644 --- a/app/src/main/java/com/kyant/glass/playground/PreviewControls.kt +++ b/app/src/main/java/com/kyant/glass/playground/PreviewControls.kt @@ -4,12 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures 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.IntrinsicSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -17,10 +18,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection.Ltr import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp import com.kyant.expressa.m3.shape.CornerShape @@ -35,7 +39,11 @@ fun PreviewControls( rect: () -> Rect, modifier: Modifier = Modifier ) { - Box(modifier.fillMaxSize()) { + val layoutDirection = LocalLayoutDirection.current + + BoxWithConstraints(modifier.fillMaxSize()) { + val containerSize = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat()) + // width Icon( painterResource(R.drawable.height_24px), @@ -44,7 +52,9 @@ fun PreviewControls( Modifier .graphicsLayer { val rect = rect() - translationX = rect.right + 8.dp.toPx() + translationX = + if (layoutDirection == Ltr) rect.right + 8.dp.toPx() + else -containerSize.width + rect.left - 8.dp.toPx() translationY = rect.center.y - size.height / 2 } .border( @@ -56,7 +66,7 @@ fun PreviewControls( .background(tertiaryContainer) .pointerInput(Unit) { detectDragGestures { change, dragAmount -> - val delta = dragAmount.x * 2 + val delta = dragAmount.x * 2f * if (layoutDirection == Ltr) 1f else -1f state.size = state.size.copy( width = @@ -80,7 +90,9 @@ fun PreviewControls( Modifier .graphicsLayer { val rect = rect() - translationX = rect.center.x - size.width / 2 + translationX = + if (layoutDirection == Ltr) rect.center.x - size.width / 2 + else -containerSize.width + rect.center.x + size.width / 2 translationY = rect.bottom + 8.dp.toPx() } .border( @@ -123,15 +135,20 @@ fun PreviewControls( val rect = rect() val cornerRadiusOffset = (-state.cornerRadius.value.toPx() + 8.dp.toPx()) / (2 * sqrt(2f)) - translationX = rect.right + cornerRadiusOffset + translationX = + if (layoutDirection == Ltr) rect.right + cornerRadiusOffset + else -containerSize.width + rect.left - cornerRadiusOffset translationY = rect.bottom + cornerRadiusOffset } .border( 2.dp, primary, CornerShape.full - ), - dragDelta = { dragAmount -> (-dragAmount.x - dragAmount.y) / sqrt(2f) } + ) + .requiredWidth(IntrinsicSize.Max), + dragDelta = { dragAmount -> + -((dragAmount.x * if (layoutDirection == Ltr) 1f else -1f) + dragAmount.y) / sqrt(2f) + } ) // refraction @@ -140,7 +157,9 @@ fun PreviewControls( .width(IntrinsicSize.Max) .graphicsLayer { val rect = rect() - translationX = rect.center.x - size.width / 2 + translationX = + if (layoutDirection == Ltr) rect.center.x - size.width / 2 + else -containerSize.width + rect.center.x + size.width / 2 translationY = rect.top - size.height - 8.dp.toPx() }, horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/com/kyant/glass/playground/ButtonGroup.kt b/app/src/main/java/com/kyant/glass/playground/PreviewToolbar.kt similarity index 72% rename from app/src/main/java/com/kyant/glass/playground/ButtonGroup.kt rename to app/src/main/java/com/kyant/glass/playground/PreviewToolbar.kt index ca0a010..1fde35f 100644 --- a/app/src/main/java/com/kyant/glass/playground/ButtonGroup.kt +++ b/app/src/main/java/com/kyant/glass/playground/PreviewToolbar.kt @@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -21,18 +23,23 @@ import androidx.compose.ui.unit.dp import com.kyant.expressa.components.iconbutton.IconButton import com.kyant.expressa.components.iconbutton.IconButtonColors import com.kyant.expressa.components.iconbutton.IconButtonSizes +import com.kyant.expressa.m3.shape.CornerShape import com.kyant.expressa.prelude.* import com.kyant.expressa.ui.Icon import com.kyant.glass.R +import com.kyant.liquidglass.LiquidGlassStyle +import com.kyant.liquidglass.liquidGlass import kotlinx.coroutines.launch @Composable -fun ButtonGroup( +fun PreviewToolbar( state: PreviewState, modifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() + val iconButtonLiquidGlassStyle = remember { LiquidGlassStyle(CornerShape.full) } + Row( modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -40,10 +47,11 @@ fun ButtonGroup( ) { IconButton( { scope.launch { state.reset() } }, + Modifier.liquidGlass(iconButtonLiquidGlassStyle), sizes = IconButtonSizes.medium, colors = IconButtonColors.tonal( - containerColor = tertiaryContainer, - contentColor = onTertiaryContainer + containerColor = primary.copy(alpha = 0.6f), + contentColor = onPrimary ) ) { Icon( @@ -63,10 +71,15 @@ fun ButtonGroup( ) { IconButton( { state.displayControls = !state.displayControls }, + Modifier.liquidGlass( + iconButtonLiquidGlassStyle.copy + (whitePoint = if (state.displayControls) 0.5f else -0.5f) + ), sizes = IconButtonSizes.medium, - colors = - if (state.displayControls) IconButtonColors.filled() - else IconButtonColors.tonal() + colors = IconButtonColors.tonal( + containerColor = Color.Transparent, + contentColor = if (state.displayControls) Color.Black else Color.White + ) ) { Icon( painterResource( @@ -81,8 +94,12 @@ fun ButtonGroup( } IconButton( { state.configurationMode = ConfigurationMode.Colors }, + Modifier.liquidGlass(iconButtonLiquidGlassStyle.copy(whitePoint = -0.5f)), sizes = IconButtonSizes.medium, - colors = IconButtonColors.tonal() + colors = IconButtonColors.tonal( + containerColor = Color.Transparent, + contentColor = Color.White + ) ) { Icon( painterResource(R.drawable.colors_24px), @@ -91,10 +108,11 @@ fun ButtonGroup( } IconButton( { state.configurationMode = ConfigurationMode.Advanced }, + Modifier.liquidGlass(iconButtonLiquidGlassStyle.copy(whitePoint = -0.5f)), sizes = IconButtonSizes.medium, colors = IconButtonColors.tonal( - containerColor = primaryContainer, - contentColor = onPrimaryContainer + containerColor = Color.Transparent, + contentColor = Color.White ) ) { Icon( @@ -121,7 +139,12 @@ fun ButtonGroup( ) ) }, - sizes = IconButtonSizes.medium + Modifier.liquidGlass(iconButtonLiquidGlassStyle), + sizes = IconButtonSizes.medium, + colors = IconButtonColors.tonal( + containerColor = primary.copy(alpha = 0.6f), + contentColor = onPrimary + ) ) { Icon( painterResource(R.drawable.add_photo_alternate_24px), diff --git a/app/src/main/java/com/kyant/glass/playground/SliderChip.kt b/app/src/main/java/com/kyant/glass/playground/SliderChip.kt index 2576662..5595a7f 100644 --- a/app/src/main/java/com/kyant/glass/playground/SliderChip.kt +++ b/app/src/main/java/com/kyant/glass/playground/SliderChip.kt @@ -1,19 +1,24 @@ package com.kyant.glass.playground import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.LayoutDirection.Ltr import androidx.compose.ui.unit.dp import com.kyant.expressa.m3.motion.MotionScheme import com.kyant.expressa.m3.shape.CornerShape @@ -21,6 +26,8 @@ import com.kyant.expressa.prelude.* import com.kyant.expressa.ui.LocalTextStyle import com.kyant.expressa.ui.ProvideTextStyle import com.kyant.expressa.ui.Text +import com.kyant.liquidglass.LiquidGlassStyle +import com.kyant.liquidglass.liquidGlass @Composable fun > SliderChip( @@ -30,19 +37,32 @@ fun > SliderChip( modifier: Modifier = Modifier, dragDelta: (dragAmount: Offset) -> Float = { it.x } ) { - val containerColor = surfaceContainer + val layoutDirection = LocalLayoutDirection.current + val sliderColor = primaryContainer val unsafeColors = yellowStaticColors val errorColor = errorContainer Row( modifier + .liquidGlass( + remember { + LiquidGlassStyle( + shape = CornerShape.extraLarge, + chromaMultiplier = 1.5f + ) + } + ) .clip(CornerShape.full) + .background(surfaceBright.copy(alpha = 0.6f)) .drawBehind { if (value.isValid) { - drawRect(containerColor) + // drawRect(containerColor) drawRect( if (value.isSafe) sliderColor else unsafeColors.accentContainer, + topLeft = + if (layoutDirection == Ltr) Offset.Zero + else Offset(size.width * (1f - value.progress), 0f), size = size.copy(width = size.width * value.progress) ) } else { @@ -51,7 +71,7 @@ fun > SliderChip( } .pointerInput(Unit) { detectDragGestures { change, dragAmount -> - val delta = dragDelta(dragAmount) / size.width + val delta = dragDelta(dragAmount) / size.width * if (layoutDirection == Ltr) 1f else -1f value.setProgress(value.progress + delta) } } @@ -74,20 +94,24 @@ fun > SliderChip( if (label != null) labelMedium else labelLarge ) { - if (value.isValid) { - Text( - value.label, - if (value.isSafe) primary else unsafeColors.accent - ) - } else { - Row { + CompositionLocalProvider( + LocalLayoutDirection provides Ltr + ) { + if (value.isValid) { Text( - value.unsafeLabel, - LocalTextStyle.current.copy(textDecoration = TextDecoration.LineThrough), - error + value.label, + if (value.isSafe) primary else unsafeColors.accent ) - Text(" -> ") - Text(value.label) + } else { + Row { + Text( + value.unsafeLabel, + LocalTextStyle.current.copy(textDecoration = TextDecoration.LineThrough), + error + ) + Text(" -> ") + Text(value.label) + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index 5c98ad0..5ca1837 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab5c2af..1a0a91e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,5 +20,6 @@ androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shape [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/library/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/library/build.gradle.kts b/library/build.gradle.kts new file mode 100644 index 0000000..e6a95c0 --- /dev/null +++ b/library/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.kyant.liquidglass" + compileSdk = 36 + buildToolsVersion = "36.0.0" + + defaultConfig { + minSdk = 21 + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlin { + compilerOptions { + apiVersion = KotlinVersion.KOTLIN_2_3 + languageVersion = KotlinVersion.KOTLIN_2_3 + jvmTarget = JvmTarget.JVM_21 + } + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) +} diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/library/proguard-rules.pro b/library/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/library/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/library/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/library/src/main/java/com/kyant/liquidglass/LiquidGlassModifier.kt b/library/src/main/java/com/kyant/liquidglass/LiquidGlassModifier.kt new file mode 100644 index 0000000..175f958 --- /dev/null +++ b/library/src/main/java/com/kyant/liquidglass/LiquidGlassModifier.kt @@ -0,0 +1,153 @@ +package com.kyant.liquidglass + +import android.graphics.BlendMode +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import androidx.compose.foundation.border +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp + +@Composable +fun Modifier.liquidGlass( + style: LiquidGlassStyle, + providerState: LiquidGlassProviderState = LocalLiquidGlassProviderState.current, + state: LiquidGlassState = rememberLiquidGlassState() +): Modifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.2f), + shape = style.shape + ) + .graphicsLayer { + clip = true + shape = style.shape + } + .drawWithCache { + val contentBlurRadiusPx = style.blurRadius.toPx() + val contentRenderEffect = + if (contentBlurRadiusPx > 0f) { + RenderEffect.createBlurEffect( + contentBlurRadiusPx, + contentBlurRadiusPx, + Shader.TileMode.DECAL + ) + } else { + RenderEffect.createOffsetEffect(0f, 0f) + } + + val cornerRadiusPx = style.shape.topStart.toPx(size, this) + + val refractionRenderEffect = + RenderEffect.createChainEffect( + RenderEffect.createRuntimeShaderEffect( + state.refractionShader.apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("cornerRadius", cornerRadiusPx) + + setFloatUniform("refractionHeight", style.refractionHeight.toPx()) + setFloatUniform("refractionAmount", style.refractionAmount.toPx()) + setFloatUniform("eccentricFactor", style.eccentricFactor) + + setFloatUniform("bleedOpacity", style.bleedOpacity) + }, + "image" + ), + contentRenderEffect + ) + + val refractionAndBleedRenderEffect = + if (style.bleedOpacity > 0f) { + val bleedRenderEffect = + RenderEffect.createChainEffect( + RenderEffect.createRuntimeShaderEffect( + state.bleedShader.apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("cornerRadius", cornerRadiusPx) + + setFloatUniform("eccentricFactor", style.eccentricFactor) + setFloatUniform("bleedAmount", style.bleedAmount.toPx()) + }, + "image" + ), + contentRenderEffect + ) + + val bleedBlurRadiusPx = style.bleedBlurRadius.toPx() + val blurredBleedRenderEffect = + if (bleedBlurRadiusPx > 0f) { + RenderEffect.createChainEffect( + bleedRenderEffect, + RenderEffect.createBlurEffect( + bleedBlurRadiusPx, + bleedBlurRadiusPx, + Shader.TileMode.CLAMP + ) + ) + } else { + bleedRenderEffect + } + + RenderEffect.createBlendModeEffect( + blurredBleedRenderEffect, + refractionRenderEffect, + BlendMode.SRC_OVER + ) + } else { + refractionRenderEffect + } + + val contrast = style.contrast + val whitePoint = style.whitePoint + val chromaMultiplier = style.chromaMultiplier + val renderEffect = + if (contrast != 0f || whitePoint != 0f || chromaMultiplier != 1f) { + RenderEffect.createChainEffect( + RenderEffect.createRuntimeShaderEffect( + state.colorManipulationShader.apply { + setFloatUniform("contrast", contrast) + setFloatUniform("whitePoint", whitePoint) + setFloatUniform("chromaMultiplier", chromaMultiplier) + }, + "image" + ), + refractionAndBleedRenderEffect + ).asComposeRenderEffect() + } else { + refractionAndBleedRenderEffect.asComposeRenderEffect() + } + + state.graphicsLayer.renderEffect = renderEffect + + onDrawBehind { + val rect = state.rect ?: return@onDrawBehind + state.graphicsLayer.record { + withTransform({ + clipRect(0f, 0f, size.width, size.height) + translate(-rect.left, -rect.top) + }) { + drawLayer(providerState.graphicsLayer) + } + } + drawLayer(state.graphicsLayer) + } + } + .onGloballyPositioned { layoutCoordinates -> + state.rect = providerState.rect?.let { + layoutCoordinates.boundsInRoot().translate(-it.topLeft) + } + } + } else { + this + } diff --git a/library/src/main/java/com/kyant/liquidglass/LiquidGlassProviderState.kt b/library/src/main/java/com/kyant/liquidglass/LiquidGlassProviderState.kt new file mode 100644 index 0000000..50549c6 --- /dev/null +++ b/library/src/main/java/com/kyant/liquidglass/LiquidGlassProviderState.kt @@ -0,0 +1,58 @@ +package com.kyant.liquidglass + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned + +val LocalLiquidGlassProviderState: ProvidableCompositionLocal = + staticCompositionLocalOf { error("CompositionLocal LocalLiquidGlassProviderState not present") } + +@Composable +fun rememberLiquidGlassProviderState(): LiquidGlassProviderState { + val graphicsLayer = rememberGraphicsLayer() + return remember(graphicsLayer) { + LiquidGlassProviderState(graphicsLayer) + } +} + +class LiquidGlassProviderState +internal constructor( + internal val graphicsLayer: GraphicsLayer, +) { + + internal var rect: Rect? by mutableStateOf(null) +} + +fun Modifier.liquidGlassProvider( + state: LiquidGlassProviderState, + backgroundColor: Color? = null +): Modifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this + .drawWithContent { + drawContent() + + state.graphicsLayer.record { + backgroundColor?.let { drawRect(it) } + this@drawWithContent.drawContent() + } + } + .onGloballyPositioned { layoutCoordinates -> + state.rect = layoutCoordinates.boundsInRoot() + } + } else { + this + } diff --git a/library/src/main/java/com/kyant/liquidglass/LiquidGlassShaders.kt b/library/src/main/java/com/kyant/liquidglass/LiquidGlassShaders.kt new file mode 100644 index 0000000..cd5bb78 --- /dev/null +++ b/library/src/main/java/com/kyant/liquidglass/LiquidGlassShaders.kt @@ -0,0 +1,149 @@ +package com.kyant.liquidglass + +import org.intellij.lang.annotations.Language + +internal object LiquidGlassShaders { + + @Language("AGSL") + private val colorShaderUtils = """// This file belongs to Kyant. You must not use it without permission. + const half3 rgbToY = half3(0.2126, 0.7152, 0.0722); + + float luma(half4 color) { + return dot(toLinearSrgb(color.rgb), rgbToY); + } + """.trimIndent() + + @Language("AGSL") + private val refractionShaderUtils = """// This file belongs to Kyant. You must not use it without permission. + float circleMap(float x) { + return 1.0 - sqrt(1.0 - x * x); + } + + float sdRectangle(float2 coord, float2 halfSize) { + float2 d = abs(coord) - halfSize; + float outside = length(max(d, 0.0)); + float inside = min(max(d.x, d.y), 0.0); + return outside + inside; + } + + float sdRoundedRectangle(float2 coord, float2 halfSize, float cornerRadius) { + float2 innerHalfSize = halfSize - float2(cornerRadius); + return sdRectangle(coord, innerHalfSize) - cornerRadius; + } + + float2 gradSdRoundedRectangle(float2 coord, float2 halfSize, float cornerRadius) { + float2 innerHalfSize = halfSize - float2(cornerRadius); + float2 cornerCoord = abs(coord) - innerHalfSize; + + if (cornerCoord.x >= 0.0 && cornerCoord.y >= 0.0) { + return sign(coord) * normalize(cornerCoord); + } else { + return sign(coord) * ((-cornerCoord.x < -cornerCoord.y) ? float2(1.0, 0.0) : float2(0.0, 1.0)); + } + } + + half4 refractionColor(float2 coord, float2 size, float cornerRadius, float eccentricFactor, float height, float amount) { + float2 halfSize = size * 0.5; + float2 centeredCoord = coord - halfSize; + float sd = sdRoundedRectangle(centeredCoord, halfSize, cornerRadius); + sd = min(sd, 0.0); + + if (sd <= 0.0 && -sd <= height) { + float maxGradRadius = max(min(halfSize.x, halfSize.y), cornerRadius); + float gradRadius = min(cornerRadius * 1.5, maxGradRadius); + float2 normal = gradSdRoundedRectangle(centeredCoord, halfSize, gradRadius); + + float refractedDistance = circleMap(1.0 - -sd / height) * amount; + float2 refractedDirection = normalize(normal + eccentricFactor * normalize(centeredCoord)); + float2 refractedCoord = coord + refractedDistance * refractedDirection; + if (refractedCoord.x < 0.0 || refractedCoord.x >= size.x || + refractedCoord.y < 0.0 || refractedCoord.y >= size.y) { + return half4(0.0, 0.0, 0.0, 1.0); + } + + return image.eval(refractedCoord); + } else { + return image.eval(coord); + } + } + """.trimIndent() + + @Language("AGSL") + val refractionShaderString = """// This file belongs to Kyant. You must not use it without permission. + uniform shader image; + + uniform float2 size; + uniform float cornerRadius; + + uniform float refractionHeight; + uniform float refractionAmount; + uniform float eccentricFactor; + + uniform float bleedOpacity; + + $colorShaderUtils + $refractionShaderUtils + + half4 main(float2 coord) { + half4 color = refractionColor(coord, size, cornerRadius, eccentricFactor, refractionHeight, refractionAmount); + if (bleedOpacity <= 0.0) { + return color; + } else { + float luma = luma(color); + color *= 1.0 - bleedOpacity * luma; + return color; + } + }""" + + @Language("AGSL") + val bleedShaderString = """// This file belongs to Kyant. You must not use it without permission. + uniform shader image; + + uniform float2 size; + uniform float cornerRadius; + + uniform float eccentricFactor; + uniform float bleedAmount; + + $colorShaderUtils + $refractionShaderUtils + + half4 main(float2 coord) { + half4 color = refractionColor(coord, size, cornerRadius, eccentricFactor, cornerRadius * 3.5, bleedAmount); + float luma = luma(color); + color.rgb = mix(color.rgb, half3(1.0), 0.5 * circleMap(1.0 - luma)); + return color; + }""" + + @Language("AGSL") + val colorManipulationShaderString = """// This file belongs to Kyant. You must not use it without permission. + uniform shader image; + + uniform float contrast; + uniform float whitePoint; + uniform float chromaMultiplier; + + $colorShaderUtils + + half4 saturateColor(half4 color, float amount) { + half3 linearSrgb = toLinearSrgb(color.rgb); + float y = dot(linearSrgb, rgbToY); + half3 gray = half3(y); + half3 adjustedLinearSrgb = mix(gray, linearSrgb, amount); + half3 adjustedSrgb = fromLinearSrgb(adjustedLinearSrgb); + return half4(adjustedSrgb, color.a); + } + + half4 main(float2 coord) { + half4 color = image.eval(coord); + + color = saturateColor(color, chromaMultiplier); + + float3 target = (whitePoint > 0.0) ? float3(1.0) : float3(0.0); + color.rgb = mix(color.rgb, target, abs(whitePoint)); + + color.rgb = (color.rgb - 0.5) * (1.0 + contrast) + 0.5; + + return color; + }""" +} diff --git a/library/src/main/java/com/kyant/liquidglass/LiquidGlassState.kt b/library/src/main/java/com/kyant/liquidglass/LiquidGlassState.kt new file mode 100644 index 0000000..2de3dc9 --- /dev/null +++ b/library/src/main/java/com/kyant/liquidglass/LiquidGlassState.kt @@ -0,0 +1,59 @@ +package com.kyant.liquidglass + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +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.geometry.Rect +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer + +@Composable +fun rememberLiquidGlassState(): LiquidGlassState { + val graphicsLayer = rememberGraphicsLayer() + return remember(graphicsLayer) { + LiquidGlassState(graphicsLayer) + } +} + +class LiquidGlassState internal constructor( + internal val graphicsLayer: GraphicsLayer +) { + + internal var rect: Rect? by mutableStateOf(null) + + private var _colorManipulationShader: RuntimeShader? = null + private var _refractionShader: RuntimeShader? = null + private var _bleedShader: RuntimeShader? = null + + internal val colorManipulationShader: RuntimeShader + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + get() { + if (_colorManipulationShader == null) { + _colorManipulationShader = RuntimeShader(LiquidGlassShaders.colorManipulationShaderString) + } + return _colorManipulationShader!! + } + + internal val refractionShader: RuntimeShader + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + get() { + if (_refractionShader == null) { + _refractionShader = RuntimeShader(LiquidGlassShaders.refractionShaderString) + } + return _refractionShader!! + } + + internal val bleedShader: RuntimeShader + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + get() { + if (_bleedShader == null) { + _bleedShader = RuntimeShader(LiquidGlassShaders.bleedShaderString) + } + return _bleedShader!! + } +} diff --git a/library/src/main/java/com/kyant/liquidglass/LiquidGlassStyle.kt b/library/src/main/java/com/kyant/liquidglass/LiquidGlassStyle.kt new file mode 100644 index 0000000..5ebaaaf --- /dev/null +++ b/library/src/main/java/com/kyant/liquidglass/LiquidGlassStyle.kt @@ -0,0 +1,173 @@ +package com.kyant.liquidglass + +import androidx.annotation.FloatRange +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Suppress("FunctionName") +fun LiquidGlassStyle( + shape: CornerBasedShape, + blurRadius: Dp = 2.dp, + + refractionHeight: Dp = 8.dp, + refractionAmount: Dp = (-16).dp, + @FloatRange(from = 0.0, to = 1.0) eccentricFactor: Float = 1f, + + bleedAmount: Dp = 0.dp, + bleedBlurRadius: Dp = 0.dp, + @FloatRange(from = 0.0, to = 1.0) bleedOpacity: Float = 0f, + + @FloatRange(from = -1.0, to = 1.0) contrast: Float = 0f, + @FloatRange(from = -1.0, to = 1.0) whitePoint: Float = 0f, + @FloatRange(from = 0.5, to = 2.0) chromaMultiplier: Float = 1f +): ImmutableLiquidGlassStyle = + ImmutableLiquidGlassStyle( + shape = shape, + blurRadius = blurRadius, + refractionHeight = refractionHeight, + refractionAmount = refractionAmount, + eccentricFactor = eccentricFactor, + bleedAmount = bleedAmount, + bleedBlurRadius = bleedBlurRadius, + bleedOpacity = bleedOpacity, + contrast = contrast, + whitePoint = whitePoint, + chromaMultiplier = chromaMultiplier + ) + +fun MutableLiquidGlassState( + shape: CornerBasedShape, + blurRadius: Dp = 2.dp, + + refractionHeight: Dp = 8.dp, + refractionAmount: Dp = (-16).dp, + @FloatRange(from = 0.0, to = 1.0) eccentricFactor: Float = 1f, + + bleedAmount: Dp = 0.dp, + bleedBlurRadius: Dp = 0.dp, + @FloatRange(from = 0.0, to = 1.0) bleedOpacity: Float = 0f, + + @FloatRange(from = -1.0, to = 1.0) contrast: Float = 0f, + @FloatRange(from = -1.0, to = 1.0) whitePoint: Float = 0f, + @FloatRange(from = 0.5, to = 2.0) chromaMultiplier: Float = 1f +): MutableLiquidGlassState = + MutableLiquidGlassState( + initialStyle = ImmutableLiquidGlassStyle( + shape, + blurRadius, + refractionHeight, + refractionAmount, + eccentricFactor, + bleedAmount, + bleedBlurRadius, + bleedOpacity, + contrast, + whitePoint, + chromaMultiplier + ) + ) + +sealed interface LiquidGlassStyle { + + val shape: CornerBasedShape + + val blurRadius: Dp + + val refractionHeight: Dp + + val refractionAmount: Dp + + @get:FloatRange(from = 0.0, to = 1.0) + val eccentricFactor: Float + + val bleedAmount: Dp + + val bleedBlurRadius: Dp + + @get:FloatRange(from = 0.0, to = 1.0) + val bleedOpacity: Float + + @get:FloatRange(from = -1.0, to = 1.0) + val contrast: Float + + @get:FloatRange(from = -1.0, to = 1.0) + val whitePoint: Float + + @get:FloatRange(from = 0.5, to = 2.0) + val chromaMultiplier: Float +} + +class ImmutableLiquidGlassStyle +internal constructor( + override val shape: CornerBasedShape, + override val blurRadius: Dp = 2.dp, + + override val refractionHeight: Dp = 8.dp, + override val refractionAmount: Dp = (-16).dp, + @param:FloatRange(from = 0.0, to = 1.0) override val eccentricFactor: Float = 1f, + + override val bleedAmount: Dp = 0.dp, + override val bleedBlurRadius: Dp = 0.dp, + @param:FloatRange(from = 0.0, to = 1.0) override val bleedOpacity: Float = 0f, + + @param:FloatRange(from = -1.0, to = 1.0) override val contrast: Float = 0f, + @param:FloatRange(from = -1.0, to = 1.0) override val whitePoint: Float = 0f, + @param:FloatRange(from = 0.5, to = 2.0) override val chromaMultiplier: Float = 1f +) : LiquidGlassStyle { + + fun copy( + shape: CornerBasedShape = this.shape, + blurRadius: Dp = this.blurRadius, + + refractionHeight: Dp = this.refractionHeight, + refractionAmount: Dp = this.refractionAmount, + @FloatRange(from = 0.0, to = 1.0) eccentricFactor: Float = this.eccentricFactor, + + bleedAmount: Dp = this.bleedAmount, + bleedBlurRadius: Dp = this.bleedBlurRadius, + @FloatRange(from = 0.0, to = 1.0) bleedOpacity: Float = this.bleedOpacity, + + @FloatRange(from = -1.0, to = 1.0) contrast: Float = this.contrast, + @FloatRange(from = -1.0, to = 1.0) whitePoint: Float = this.whitePoint, + @FloatRange(from = 0.5, to = 2.0) chromaMultiplier: Float = this.chromaMultiplier + ): ImmutableLiquidGlassStyle = + ImmutableLiquidGlassStyle( + shape = shape, + blurRadius = blurRadius, + refractionHeight = refractionHeight, + refractionAmount = refractionAmount, + eccentricFactor = eccentricFactor, + bleedAmount = bleedAmount, + bleedBlurRadius = bleedBlurRadius, + bleedOpacity = bleedOpacity, + contrast = contrast, + whitePoint = whitePoint, + chromaMultiplier = chromaMultiplier + ) +} + +class MutableLiquidGlassState +internal constructor( + val initialStyle: LiquidGlassStyle +) : LiquidGlassStyle { + + override var shape: CornerBasedShape by mutableStateOf(initialStyle.shape) + override var blurRadius: Dp by mutableStateOf(initialStyle.blurRadius) + + override var refractionHeight: Dp by mutableStateOf(initialStyle.refractionHeight) + override var refractionAmount: Dp by mutableStateOf(initialStyle.refractionAmount) + override var eccentricFactor: Float by mutableFloatStateOf(initialStyle.eccentricFactor) + + override var bleedAmount: Dp by mutableStateOf(initialStyle.bleedAmount) + override var bleedBlurRadius: Dp by mutableStateOf(initialStyle.bleedBlurRadius) + override var bleedOpacity: Float by mutableFloatStateOf(initialStyle.bleedOpacity) + + override var contrast: Float by mutableFloatStateOf(initialStyle.contrast) + override var whitePoint: Float by mutableFloatStateOf(initialStyle.whitePoint) + override var chromaMultiplier: Float by mutableFloatStateOf(initialStyle.chromaMultiplier) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ece87e..ff6489a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,4 +21,4 @@ dependencyResolutionManagement { rootProject.name = "Glass" include(":app") - \ No newline at end of file +include(":library")