diff --git a/docs/index.md b/docs/index.md index 92296ab1..72f977a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,30 @@ -[![Maven Central](https://img.shields.io/maven-central/v/dev.chrisbanes.haze/haze)](https://search.maven.org/search?q=g:dev.chrisbanes.haze) +Haze is a library providing a 'glassmorpism' style blur for Compose. + +It is built with Compose Multiplatform, meaning that we support multiple platforms: + +| Platform | Supported | +|---------------|------------------| +| Android | ✅ (read below) | +| iOS | ✅ | +| Desktop (JVM) | ✅ | + +### Android + +The situation on Android is slighty tricky right now. The main Haze library is built with Compose Multiplatform, which currently uses Compose UI 1.5.x. + +The Android implementation of `Modifier.haze` we have requires some drawing APIs which were recently added in the Compose UI 1.6.0 (alphas). Since we do not have access to those APIs when building with Compose Multiplatform, the Android target in the main library currently displays a translucent scrim, without a blur. + +#### Jetpack Compose +If you are **not** building with Compose Multiplatform (i.e. Android only), and can use the latest Jetpack Compose 1.6.0 alphas, we've also published a version of the library specifically targetting Jetpack Compose. This version contains the 'real' blur implementation: + +```kotlin +dependencies { + implementation("dev.chrisbanes.haze:haze-jetpack-compose:") +} +``` + +The API is exactly the same as it's basically a copy. Once Compose Multiplatform is updated to use Jetpack Compose UI 1.6.0 in the future, this extension library will no longer be required, and eventually removed. ## Download @@ -11,7 +36,11 @@ repositories { } dependencies { - implementation "dev.chrisbanes.haze:haze:" + // For Compose Multiplatform + implementation("dev.chrisbanes.haze:haze:") + + // Or if you're Android only + implementation("dev.chrisbanes.haze:haze-jetpack-compose:") } ``` @@ -36,4 +65,4 @@ limitations under the License. ``` [compose]: https://developer.android.com/jetpack/compose -[snap]: https://oss.sonatype.org/content/repositories/snapshots/dev/chrisbanes/haze/haze/ +[snap]: https://oss.sonatype.org/content/repositories/snapshots/dev/chrisbanes/haze/ diff --git a/gradle/build-logic/convention/src/main/kotlin/dev/chrisbanes/gradle/KotlinMultiplatformConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/dev/chrisbanes/gradle/KotlinMultiplatformConventionPlugin.kt index b6695799..2fdd48af 100644 --- a/gradle/build-logic/convention/src/main/kotlin/dev/chrisbanes/gradle/KotlinMultiplatformConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/dev/chrisbanes/gradle/KotlinMultiplatformConventionPlugin.kt @@ -21,7 +21,9 @@ class KotlinMultiplatformConventionPlugin : Plugin { jvm() if (pluginManager.hasPlugin("com.android.library")) { - androidTarget() + androidTarget { + publishLibraryVariants("release") + } } listOf( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f8b336c..ebf32b9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,5 @@ [versions] agp = "8.1.2" -composecompiler = "1.5.3" compose-multiplatform = "1.5.10-rc02" ktlint = "1.0.1" kotlin = "1.9.10" @@ -20,6 +19,7 @@ mavenpublish = { id = "com.vanniktech.maven.publish", version = "0.25.3" } [libraries] imageloader = "io.github.qdsfdhvh:image-loader:1.6.8" +androidx-compose-ui = "androidx.compose.ui:ui:1.6.0-alpha08" androidx-core = "androidx.core:core-ktx:1.12.0" androidx-activity-compose = "androidx.activity:activity-compose:1.8.0" diff --git a/haze-jetpack-compose/build.gradle.kts b/haze-jetpack-compose/build.gradle.kts new file mode 100644 index 00000000..00ad32fd --- /dev/null +++ b/haze-jetpack-compose/build.gradle.kts @@ -0,0 +1,18 @@ +// Copyright 2023, Christopher Banes and the Haze project contributors +// SPDX-License-Identifier: Apache-2.0 + + +plugins { + id("dev.chrisbanes.android.library") + id("dev.chrisbanes.kotlin.android") + id("org.jetbrains.dokka") + id("com.vanniktech.maven.publish") +} + +android { + namespace = "dev.chrisbanes.haze.jetpackcompose" +} + +dependencies { + api(libs.androidx.compose.ui) +} diff --git a/haze-jetpack-compose/gradle.properties b/haze-jetpack-compose/gradle.properties new file mode 100644 index 00000000..7eda58d2 --- /dev/null +++ b/haze-jetpack-compose/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=haze-jetpack-compose +POM_NAME=Haze (Jetpack Compose) diff --git a/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/Haze.jetpackcompose.kt b/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/Haze.jetpackcompose.kt new file mode 100644 index 00000000..024bd9d6 --- /dev/null +++ b/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/Haze.jetpackcompose.kt @@ -0,0 +1,156 @@ +// Copyright 2023, Christopher Banes and the Haze project contributors +// SPDX-License-Identifier: Apache-2.0 + +package dev.chrisbanes.haze + +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.graphics.RenderEffect +import android.graphics.RenderNode +import android.graphics.Shader +import android.os.Build +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.draw +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Modifier.haze( + vararg area: Rect, + backgroundColor: Color, + tint: Color = HazeDefaults.tint(backgroundColor), + blurRadius: Dp = HazeDefaults.blurRadius, +): Modifier = haze( + areas = area.toList(), + tint = tint, + backgroundColor = backgroundColor, + blurRadius = blurRadius, +) + +/** + * Defaults for the [haze] modifiers. + */ +object HazeDefaults { + /** + * Default blur radius. Larger values produce a stronger blur effect. + */ + val blurRadius: Dp = 20.dp + + /** + * Default alpha used for the tint color. Used by the [tint] function. + */ + val tintAlpha: Float = 0.7f + + /** + * Default builder for the 'tint' color. Transforms the provided [color]. + */ + fun tint(color: Color): Color = color.copy(alpha = tintAlpha) +} + +internal fun Modifier.haze( + areas: List, + backgroundColor: Color, + tint: Color, + blurRadius: Dp, +): Modifier { + if (Build.VERSION.SDK_INT < 31) { + // On older platforms we display a translucent scrim + return drawWithContent { + drawContent() + + for (area in areas) { + // We need to boost the alpha as we don't have a blur effect + drawRect( + color = tint.copy(alpha = (tint.alpha * 1.35f).coerceAtMost(1f)), + topLeft = area.topLeft, + size = area.size, + ) + } + } + } + + return drawWithCache { + // This is our RenderEffect. It first applies a blur effect, and then a color filter effect + // to allow content to be visible on top + val blurRadiusPx = blurRadius.toPx() + val effect = RenderEffect.createColorFilterEffect( + BlendModeColorFilter(tint.toArgb(), BlendMode.SRC_OVER), + RenderEffect.createBlurEffect(blurRadiusPx, blurRadiusPx, Shader.TileMode.DECAL), + ) + + val contentNode = RenderNode("content").apply { + setPosition(0, 0, size.width.toInt(), size.height.toInt()) + } + + // We create a RenderNode for each of the areas we need to apply our effect to + val effectRenderNodes = areas.map { area -> + // We expand the area where our effect is applied to. This is necessary so that the blur + // effect is applied evenly to allow edges. If we don't do this, the blur effect is much less + // visible on the edges of the area. + val expandedRect = area.inflate(blurRadiusPx) + + val node = RenderNode("blur").apply { + setRenderEffect(effect) + setPosition(0, 0, expandedRect.width.toInt(), expandedRect.height.toInt()) + translationX = expandedRect.left + translationY = expandedRect.top + } + EffectRenderNodeHolder(renderNode = node, renderNodeDrawArea = expandedRect, area = area) + } + + onDrawWithContent { + // First we draw the composable content into `contentNode` + Canvas(contentNode.beginRecording()).also { canvas -> + draw(this, layoutDirection, canvas, size) { + this@onDrawWithContent.drawContent() + } + contentNode.endRecording() + } + + // Now we draw `contentNode` into the window canvas, so that it is displayed + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawRenderNode(contentNode) + } + + // Now we need to draw `contentNode` into each of our 'effect' RenderNodes, allowing + // their RenderEffect to be applied to the composable content. + effectRenderNodes.forEach { effect -> + effect.renderNode.beginRecording().also { canvas -> + // We need to draw our background color first, as the `contentNode` may not draw + // a background. This then makes the blur effect much less pronounced, as blurring with + // transparent negates the effect. + canvas.drawColor(backgroundColor.toArgb()) + canvas.translate(-effect.renderNodeDrawArea.left, -effect.renderNodeDrawArea.top) + canvas.drawRenderNode(contentNode) + effect.renderNode.endRecording() + } + } + + // Finally we draw each 'effect' RenderNode to the window canvas, drawing on top + // of the original content + drawIntoCanvas { canvas -> + effectRenderNodes.forEach { effect -> + with(effect) { + clipRect(area.left, area.top, area.right, area.bottom) { + canvas.nativeCanvas.drawRenderNode(renderNode) + } + } + } + } + } + } +} + +private class EffectRenderNodeHolder( + val renderNode: RenderNode, + val renderNodeDrawArea: Rect, + val area: Rect, +) diff --git a/haze/build.gradle.kts b/haze/build.gradle.kts index 244c75e6..d7c225f8 100644 --- a/haze/build.gradle.kts +++ b/haze/build.gradle.kts @@ -26,12 +26,6 @@ kotlin { dependsOn(commonMain) } - val androidMain by getting { - dependencies { - api("androidx.compose.ui:ui:1.6.0-alpha08") - } - } - val iosMain by getting { dependsOn(skikoMain) } diff --git a/haze/src/androidMain/kotlin/dev/chrisbanes/haze/HazePlatform.kt b/haze/src/androidMain/kotlin/dev/chrisbanes/haze/HazePlatform.kt index dbb7ef64..4eacde97 100644 --- a/haze/src/androidMain/kotlin/dev/chrisbanes/haze/HazePlatform.kt +++ b/haze/src/androidMain/kotlin/dev/chrisbanes/haze/HazePlatform.kt @@ -3,23 +3,10 @@ package dev.chrisbanes.haze -import android.graphics.BlendMode -import android.graphics.BlendModeColorFilter -import android.graphics.RenderEffect -import android.graphics.RenderNode -import android.graphics.Shader -import android.os.Build import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.drawscope.draw -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Dp internal actual fun Modifier.haze( @@ -28,90 +15,19 @@ internal actual fun Modifier.haze( tint: Color, blurRadius: Dp, ): Modifier { - if (Build.VERSION.SDK_INT < 31) { - // On older platforms we just display a translucent scrim - return drawWithContent { - drawContent() - - for (area in areas) { - drawRect(color = tint, topLeft = area.topLeft, size = area.size, alpha = 0.9f) - } - } - } - - return drawWithCache { - // This is our RenderEffect. It first applies a blur effect, and then a color filter effect - // to allow content to be visible on top - val blurRadiusPx = blurRadius.toPx() - val effect = RenderEffect.createColorFilterEffect( - BlendModeColorFilter(tint.toArgb(), BlendMode.SRC_OVER), - RenderEffect.createBlurEffect(blurRadiusPx, blurRadiusPx, Shader.TileMode.DECAL), - ) - - val contentNode = RenderNode("content").apply { - setPosition(0, 0, size.width.toInt(), size.height.toInt()) - } - - // We create a RenderNode for each of the areas we need to apply our effect to - val effectRenderNodes = areas.map { area -> - // We expand the area where our effect is applied to. This is necessary so that the blur - // effect is applied evenly to allow edges. If we don't do this, the blur effect is much less - // visible on the edges of the area. - val expandedRect = area.inflate(blurRadiusPx) - - val node = RenderNode("blur").apply { - setRenderEffect(effect) - setPosition(0, 0, expandedRect.width.toInt(), expandedRect.height.toInt()) - translationX = expandedRect.left - translationY = expandedRect.top - } - EffectRenderNodeHolder(renderNode = node, renderNodeDrawArea = expandedRect, area = area) - } - - onDrawWithContent { - // First we draw the composable content into `contentNode` - Canvas(contentNode.beginRecording()).also { canvas -> - draw(this, layoutDirection, canvas, size) { - this@onDrawWithContent.drawContent() - } - contentNode.endRecording() - } - - // Now we draw `contentNode` into the window canvas, so that it is displayed - drawIntoCanvas { canvas -> - canvas.nativeCanvas.drawRenderNode(contentNode) - } - - // Now we need to draw `contentNode` into each of our 'effect' RenderNodes, allowing - // their RenderEffect to be applied to the composable content. - effectRenderNodes.forEach { effect -> - effect.renderNode.beginRecording().also { canvas -> - // We need to draw our background color first, as the `contentNode` may not draw - // a background. This then makes the blur effect much less pronounced, as blurring with - // transparent negates the effect. - canvas.drawColor(backgroundColor.toArgb()) - canvas.translate(-effect.renderNodeDrawArea.left, -effect.renderNodeDrawArea.top) - canvas.drawRenderNode(contentNode) - effect.renderNode.endRecording() - } - } - - // Finally we draw each 'effect' RenderNode to the window canvas, drawing on top - // of the original content - drawIntoCanvas { canvas -> - effectRenderNodes.forEach { effect -> - val (node, _, area) = effect - clipRect(area.left, area.top, area.right, area.bottom) { - canvas.nativeCanvas.drawRenderNode(node) - } - } - } + // With CMP + Android, we can't do much other than display a transparent scrim. + // See `:haze-jetpack-compose` for a working blur on Android, but we need Compose 1.6.0 APIs, + // which are not available in CMP (yet). + return drawWithContent { + drawContent() + + for (area in areas) { + // We need to boost the alpha as we don't have a blur effect + drawRect( + color = tint.copy(alpha = (tint.alpha * 1.35f).coerceAtMost(1f)), + topLeft = area.topLeft, + size = area.size, + ) } } } - -private data class EffectRenderNodeHolder( - val renderNode: RenderNode, - val renderNodeDrawArea: Rect, - val area: Rect, -) diff --git a/haze/src/commonMain/kotlin/dev/chrisbanes/haze/Haze.kt b/haze/src/commonMain/kotlin/dev/chrisbanes/haze/Haze.kt index 2081772a..ba0fb830 100644 --- a/haze/src/commonMain/kotlin/dev/chrisbanes/haze/Haze.kt +++ b/haze/src/commonMain/kotlin/dev/chrisbanes/haze/Haze.kt @@ -21,10 +21,22 @@ fun Modifier.haze( blurRadius = blurRadius, ) +/** + * Defaults for the [haze] modifiers. + */ object HazeDefaults { + /** + * Default blur radius. Larger values produce a stronger blur effect. + */ val blurRadius: Dp = 20.dp + /** + * Default alpha used for the tint color. Used by the [tint] function. + */ val tintAlpha: Float = 0.7f - fun tint(backgroundColor: Color): Color = backgroundColor.copy(alpha = tintAlpha) + /** + * Default builder for the 'tint' color. Transforms the provided [color]. + */ + fun tint(color: Color): Color = color.copy(alpha = tintAlpha) } diff --git a/settings.gradle.kts b/settings.gradle.kts index c0b3cc1e..f0f2c078 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,6 +43,7 @@ rootProject.name = "haze-root" include( ":haze", + ":haze-jetpack-compose", ":sample:shared", ":sample:android", ":sample:desktop",