Tutorial Android AGSL — Enhance your Compose Component with shaders.
Note: This tutorial uses a shader script from the Android Open Source Project, licensed under the Apache 2.0 License. Adjustments have…
Tutorial Android AGSL — Enhance your Compose Component with shaders.
Note: This tutorial uses a shader script from the Android Open Source Project, licensed under the Apache 2.0 License. Adjustments have been made to integrate it with Jetpack Compose. The component is heavily inspired in this attempt tutorial (https://proandroiddev.com/threads-invitation-card-with-jetpack-compose-2e5b9baede4)
Code: https://github.com/lluzalves/card_compose_with_agsl_shaders
Overview
Jetpack Compose empowers developers to design UI using Composable functions, a declarative framework that replaces traditional XML-based layouts. This approach provides flexibility by enabling the reuse of UI components across screens without the need for additional XML layouts or manual mappings to Activities, Screens, or Fragments.
By integrating AGSL (Android Graphics Shading Language), you can create stunning UI components and elevate user experiences. AGSL shaders are supported in Android 13 and later, making it possible to deliver visually captivating effects for both apps and games. This tutorial will guide you through implementing a Card Flip animation using shaders to create an employee card, showcasing how Compose and AGSL work together for superior visuals.

Design Phase
To conceptualize the feature, we’ll outline the interactions between the UI and Domain layers. This example uses a clean architecture pattern, focusing solely on these layers for simplicity.
Requirements
- The Card should rotate 360 degrees.
- It should have distinct front and back covers.
- Maintain a clean and professional visual appearance.
- Deliver a user experience comparable to Threads Invite Cards.
Architecture Overview
To ensure the separation of concerns, we’ll adopt a unidirectional data flow between the Domain Stack and UI Stack components.
- UI Actions: The UI sends an intent to the ViewModel.
- ViewModel: Processes the intent and forwards it to the Domain Layer.
- Domain Layer: Executes the necessary business logic and returns a result.
- State Updates: The ViewModel updates the centralized state, which the UI observes and renders.

High Level Diagram for the feature
Now, with the diagram finished, we can start developing the feature and implement it using the architectural style we aim for.
Implementation Steps
1. Create a New Project
Start by creating a new project in Android Studio.
2. Add Dependencies
Update your libs.version.toml file with the following dependencies:
[embed]
3. Update build.gradle.kts
At the project level, ensure the Compose plugin is added if not already present:
[embed]
At the app level, include the Compose and Coil dependencies:
[embed]
4. Define the Card Model
Create a CardModel.kt file to hold the data structure for the card component:
data class Card(
val id: String,
val frontText: String,
val backText: String,
val username : String,
val userImage : String,
val isFlipped: Boolean = false
)
5. Create Use Cases
In Domain.kt, define the use cases for loading and flipping the Card, we will use it in our ViewModel:
class LoadCardUseCase() {
operator fun invoke(cardId: String): Card {
val card = Card(id = cardId, frontText = "CardFlip", backText = null, username = "Daniel", useImage = "https://avatars.githubusercontent.com/u/8259531?v=4", isFlipped = false)
return card.copy(isFlipped = !card.isFlipped)
}
class GetFlippedCardUseCase() {
operator fun invoke(cardId: String): Card {
val card = Card(id = cardId, frontText = null, backText = "Card Flipped !", username = "Daniel", usermage = "https://avatars.githubusercontent.com/u/8259531?v=4", isFlipped = true)
return card.copy(isFlipped = card.isFlipped)
}
}
6. ViewModel Structure
The ViewModel works as the intermediary between the UI and the Domain Layer. It will listen to user intents, process them by invoking the appropriate use cases, and update the UI state accordingly. Maintaining a unidirectional data flow ensures a clear separation of concerns and predictable state management.
6. 1 — Set Up the CardClipState
The CardFlipSateinterface represents the various states the card flip feature can have. It will use a sealed structure to ensure all possible states are exhaustively handled in the UI.
This includes loading, error, and specific card flip states like: CardLoadingand CardFlip.
By doing so, the UI can react to these states predictably and render appropriate feedback to the user.
sealed interface CardFlipState
data object CardLoading : CardFlipState
data object CardError : CardFlipState {
var errorMessage: String = "Error"
}
6. 2 — Set Up the CardClipIntent
The CardFlipIntent class captures user interactions, such as flipping or loading a card. Each intent corresponds to an action the user can perform, which will be processed by the ViewModeland then updated in the UI state accordingly. This design separates user actions and the underlying state management.
sealed class CardFlipIntent{
data object Loading : CardFlipIntent()
data class LoadCard(val cardId : String) : CardFlipIntent()
data object FlipCard : CardFlipIntent(){
var cardId = ""
}
}
6. 3 — Set Up the ViewModel
The CardFlipViewModel listens for intents from the UI, processes them by invoking appropriate use cases, and updates the application's components state. It maintains a centralized state, allowing the UI to observe and react to state changes.
class CardFlipViewModel(
private val flipCardUseCase: GetFlippedCardUseCase,
private val loadCardUseCase: LoadCardUseCase
): ViewModel() {
private val _cardFlipState = MutableStateFlow<CardFlipState>(CardLoading)
val cardFlipState: StateFlow<CardFlipState> get() = _cardFlipState
fun onIntentReceived(intent: CardFlipIntent) {
when (intent) {
is CardFlipIntent.FlipCard -> {
flipCardSide(intent.cardId)
}
is CardFlipIntent.LoadCard -> {
loadCard(intent.cardId)
}
is CardFlipIntent.Loading ->{
_cardFlipState.value = CardLoading
}
}
}
private fun flipCardSide(cardId: String) {
viewModelScope.launch {
_cardFlipState.value = CardLoading
try {
val card = flipCardUseCase.invoke(cardId)
_cardFlipState.value = CardFlip(
isLoading = false,
card = card,
errorMessage = null
)
}catch (exception : Exception){
CardError.errorMessage = exception.localizedMessage ?: "Error"
_cardFlipState.value = CardError
}
}
}
private fun loadCard(cardId: String) {
viewModelScope.launch {
_cardFlipState.value = CardLoading
try {
val card = loadCardUseCase.invoke(cardId)
_cardFlipState.value = CardFlip(
isLoading = false,
card = card,
errorMessage = null
)
}catch (exception : Exception){
CardError.errorMessage = exception.localizedMessage ?: "Error"
_cardFlipState.value = CardError
}
}
}
}
7.0 — Finally, the IU Components.
Based on the image provided and the requirements, we can think of and define a few compose components we need to work with:

CardFlip Front

CardFlip Back
IU Key Components:
- CardFrontSide: Displays the front design of the Card, including the user’s image and details.
- CardBackSide: Defines the reverse side of the Card, focusing on branding or additional visuals.
- GestureRotationCard: Handles user gestures to rotate the Card dynamically using horizontal and vertical drags.
- InviteCardHolder: A composable wrapper that applies rotation effects to the Card using Compose’s
graphicsLayer.
Let’s create them.
7.1 — Card Components
7.1.1 — Loading
Add the following code to have a CircularProgressIndicator while the content is loaded into the application.
@Composable
fun LoadingScreen() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
CircularProgressIndicator(
color = customDeepPurple,
strokeWidth = 4.dp
)
Text(
modifier = Modifier.offset(y=40.dp),
text = CardLoading.loadingInfo
)
}
}

7.1.1 — CardBrand
This component displays a brand icon for the Card. It does so by using the Image composable to render the image resource set in painterResource.
@Composable
fun CardBrand(
modifier: Modifier
) {
Image(
modifier = modifier
.padding(all = 16.dp)
.size(size = 100.dp),
painter = painterResource(id = R.drawable.ic_mascot),
contentDescription = "Card brand icon"
)
}
- Key Points:
modifier: Allows customization of padding and size.painterResource: Loads the drawable resource (R.drawable.ic_mascot) for the brand icon.contentDescription: Provides accessibility support.
7.1.2 — CardUserImage
This code will dynamically load and display the user’s profile image. It uses the Coil library (ImageLoader) to fetch the image from a valid URL.
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun CardUserImage(userImage: String) {
val imageLoader = ImageLoader(LocalContext.current)
val request = ImageRequest.Builder(LocalContext.current)
.data(userImage)
.build()
imageLoader.enqueue(request)
ImageComponent(userImage, imageLoader, "")
}
Key Points:
userImage: URL of the user's profile image.ImageLoader: Manages image loading and caching.ImageRequest.Builder: Configures the image request.ImageComponent: A helper composable to render the fetched image.
7.1.3 — CardLabel
The CardLabel will add a title and description and define the card arrangement.
@Composable
fun CardLabel(title: String, info: String) {
Column {
Text(
modifier = Modifier.padding(
horizontal = framePadding
),
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
)
Text(
modifier = Modifier.padding(
horizontal = framePadding
),
text = info,
color = Color.White,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily.Monospace
)
)
}
}
7.1.4 — CardDashDivider
It adds a visual separator in the Card using dashed lines, leveraging the Canvas composable to draw a horizontal dashed line.
@Composable
fun CardDashDivider() {
Canvas(
Modifier
.fillMaxWidth()
.height(1.dp)
) {
drawLine(
color = Color.White,
start = Offset(0f, 0f),
strokeWidth = 2f,
end = Offset(size.width, 0f),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 14f), 0f)
)
}
}
Key Points:
Canvas: Provides a low-level drawing surface.drawLine: Draws a horizontal line with specified styling.PathEffect.dashPathEffect: Creates a dashed effect with alternating dash and gap lengths.Modifier.fillMaxWidth: Ensures the divider spans the Card's width.
With these components, we can construct the CardFrontSide and CardBackSide with more flexibility.
This structure contributes to a modular design approach for the Card, ensuring reusability and adherence to Composedeclarative principles while allowing developers to create dynamic and customizable UI elements seamlessly.
7.2 — CardFrontSide
It will display the Card’s front design, which includes user details such as the username and profile image; before we do so, we need to create one more composable function to build our Card’s front side:
7.2.1 — CardContent
Now, we can construct the content layout for the Card’s front side. To do so, we will combine multiple composables, such as CardBrand, CardUserImage, CardLabel, and CardDashDivider to create a structured and visually appealing card layout.
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun CardContent(
date: String,
userId: String,
username: String,
userImage: String,
) {
Column(
modifier = Modifier
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
CardBrand(modifier = Modifier.align(alignment = Alignment.Bottom))
CardUserImage(userImage = userImage)
}
Spacer(modifier = Modifier.height(spaceBetweenItems))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
CardLabel(title = "JOINED", info = date)
}
Spacer(modifier = Modifier.height(spaceBetweenItems))
CardLabel(title = "NAME", info = username)
Spacer(modifier = Modifier.height(spaceBetweenItems))
CardLabel(title = "ROLE", info = userId)
Spacer(modifier = Modifier.height(spaceBetweenItems))
CardDashDivider()
Spacer(modifier = Modifier.height(spaceBetweenItems))
}
}
Key Points:
- Combines reusable components like
CardBrand,CardUserImage,CardLabel, andCardDashDivider. - Uses
ColumnandRowlayouts for alignment and spacing. - Ensures modularity, allowing each part to be independently styled or replaced.
After that, we can build our Card’s front successfully.
CardFrontSide
We will now put everything together to create the visual structure for the front of the Card, combining top and bottom decoration(CardHalfCircles) and the main content.
(CardContent).@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun CardFrontSide(
cardFlip: CardFlip,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
.background(customDeepBlue),
contentAlignment = Alignment.Center,
) {
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.TopCenter))
// Card content.
CardContent(
date = Calendar.getInstance().time.toString(),
userId = cardFlip.card?.id ?: "0",
username = cardFlip.card?.username ?: "User",
userImage = cardFlip.card?.userImage ?: ""
)
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.BottomCenter))
}
}
}
Key Points:
- Combines reusable components like
CardContentandCardHalfCircles. - Uses a
Boxlayout to layer components and apply alignment. - Applies consistent styling with a background color (
customDeepBlue). - Displays user-specific data such as name, role, and profile image.
This is the result of the code above and a variation by removing one of the composable components.

CardFrontFlip Result

Variation of CardFrontFlip by removing the CardHalfCircles componets.
7.2 — CardBackSide
Now, let’s define the reverse side of the Card. Based on the design, it combines decorative elements (CardHalfCircles) with some branding (logo) and text.
@Composable
fun CardBackSide() {
Column(
Modifier
.fillMaxWidth()
.height(500.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.background(customDeepBlue),
contentAlignment = Alignment.Center,
) {
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.TopCenter))
Image(
modifier = Modifier.size(size = 160.dp),
painter = painterResource(id = R.drawable.ic_mascot),
contentDescription = ""
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.background(customDeepBlue),
contentAlignment = Alignment.TopCenter
) {
Text(
modifier = Modifier
.padding(
horizontal = framePadding,
)
.align(Alignment.TopCenter),
text = "CardFlip",
color = Color.White,
style = TextStyle(
fontSize = 40.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
)
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.BottomCenter))
}
}
}

CardBackSide result
After defining the back and front components, we must create a structure holding both sides. To achieve that, we must create one more composable. The CardHolder will act as a wrapper composable to hold the front and back sides of the Card.
@Composable
fun CardHolder(
modifier: Modifier = Modifier,
frontSide: @Composable () -> Unit = {},
backSide: @Composable () -> Unit = {},
) {
Card(
modifier = modifier
) {
Box(
Modifier.fillMaxSize()
) {
frontSide()
}
}
}
val holder = InviteCardHolder(
frontSide = { CardFrontSide(cardFlip = state) },
backSide = { CardBackSide() },
modifier = modifier
)
This implementation provides flexibility by allowing the front and back sides to be passed as composable lambdas.
Doing so ensures a clean separation between the Card’s structure and its dynamic content.
The result should look like this:

As you can see, there are still missing features, and we can’t rotate the Card to see its backside.
Moving on, we need to make a few adjustments in the code to support rotation, and then we will finally be able to rotate the Card vertically and horizontally.
7.3 — Gesture And Rotation
To support gesture and rotation, we must create a new composable that handles the user’s gestures and dynamically updates the X and Y rotations. Add the following composable function to your code.
@Composable
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun GestureAndRotationCard(
modifier: Modifier = Modifier,
state: CardFlipState
) {
var gestureAxisX by remember { mutableStateOf(0f) }
var gestureAxisY by remember { mutableStateOf(0f) }
val gestureModifier = Modifier
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
// Consume drag event
change.consume()
val (dragX, dragY) = dragAmount
// Update rotations dynamically
gestureAxisY = (gestureAxisY + dragX) % 360 // Y-axis for horizontal rotation
gestureAxisX = (gestureAxisX + dragY) % 360 // X-axis for vertical rotation
}
)
}
when (state) {
CardError -> {
}
is CardFlip -> {
InviteCardHolder(
frontSide = { CardFrontSide(cardFlip = state) },
backSide = { CardBackSide() },
positionAxisX = gestureAxisX,
positionAxisY = gestureAxisY,
modifier = modifier.then(gestureModifier)
)
}
CardLoading -> {
LoadingScreen()
}
NoCard -> {
// not mapped
}
}
}
Key Points:
- Gesture Handling:
detectDragGesturesdynamically updatesgestureAxisX(vertical rotation) andgestureAxisY(horizontal rotation) based on user input.- Both axes are updated independently, allowing smooth and natural rotation in any direction.
- Seamless Integration:
- The gesture rotations (
gestureAxisX,gestureAxisY) are passed toInviteCardHolder, ensuring dynamic updates. - Front and Back Transitions:
- Handles transitions between front and back sides automatically based on the angle.
Now, all we need to do is update the InviteCardHolder function to receive the gestures rotation and to make use of it to decide what side of the Card will be presented on the screen.
This implementation offers a natural, intuitive rotation experience with proper transitions and no axis-locking conflicts.
@Composable
fun InviteCardHolder(
modifier: Modifier = Modifier,
positionAxisY: Float,
positionAxisX: Float,
frontSide: @Composable () -> Unit = {},
backSide: @Composable () -> Unit = {},
) {
val isYAxisBackSide = abs(positionAxisY.toInt()) % 360 in 91..270
val isXAxisBackSide = abs(positionAxisX.toInt()) % 360 in 91..270
Card(
modifier = modifier
.graphicsLayer {
rotationX = positionAxisX
rotationY = positionAxisY
cameraDistance = 14f * density
},
) {
when {
isYAxisBackSide -> {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
// Correct back-side rotation for Y-axis
rotationY = 180f
}
) {
backSide()
}
}
isXAxisBackSide -> {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
// Correct back-side rotation for X-axis
rotationX = 180f
}
) {
backSide()
}
}
else -> {
Box(Modifier.fillMaxSize()) {
frontSide()
}
}
}
}
}

Now our card support gesture and reacts to it.
8.0 — AGSL (Android Graphics Shading Language) Support in Compose. Finally!
We can now combine AGSL with our Jetpack Compose components to transform our UI/UX experience into something more visually captivating beyond static designs.
Key Benefits of Using AGSL with Compose:
Dynamic Effects:
- Shaders allow for creating complex visual effects like animations, gradients, and distortions, enhancing user engagement.
GPU Acceleration:
- By leveraging the GPU, AGSL ensures high performance even for demanding visual effects.
Declarative Integration:
- Jetpack Compose’s declarative approach seamlessly integrates with AGSL, enabling modular and reusable shader components.
Enhanced User Experience:
- Custom shader effects can make your UI stand out, offering a polished and professional look.
8.1 — The Solar Flair Shader
We need to define a shader script that creates a solar wave effect to do so.
The Script
The shader is implemented as a Kotlin variable annotated with @Language("AGSL") to ensure proper syntax highlighting and editor support.
package com.daniel.compose.shadersexperimentsapp.agsl
import org.intellij.lang.annotations.Language
@Language("AGSL")
val SOLAR_FLAIR_SHADER = """
uniform float2 resolution;
uniform float time;
layout(color) uniform half4 baseColor;
layout(color) uniform half4 backgroundColor;
const int ITERATIONS = 1;
const float INTENSITY = 100.0;
const float TIME_MULTIPLIER = 0.25;
float4 main(in float2 fragCoord) {
// Slow down the animation to be more soothing
float calculatedTime = time * TIME_MULTIPLIER;
// Coords
float2 uv = fragCoord / resolution.xy;
float2 uvCalc = (uv * 6.0) - (INTENSITY * 1.0);
// Values to adjust per iteration
float2 iterationChange = float2(uvCalc);
float colorPart = 1.0;
for (int i = 0; i < ITERATIONS; i++) {
iterationChange = uvCalc + float2(
cos(calculatedTime + iterationChange.x) +
sin(calculatedTime - iterationChange.y),
cos(calculatedTime - iterationChange.x) +
sin(calculatedTime + iterationChange.y)
);
colorPart += 0.8 / length(
float2(uvCalc.x / (cos(iterationChange.x + calculatedTime) * INTENSITY),
uvCalc.y / (sin(iterationChange.y + calculatedTime) * INTENSITY)
)
);
}
colorPart = 2.0 - (colorPart / float(ITERATIONS));
// Fade out the bottom on a curve
float mixRatio = 1.0 - (uv.y * uv.y);
// Mix calculated color with the incoming base color
float4 color = float4(colorPart * baseColor.r, colorPart * baseColor.g, colorPart * baseColor.b, 1.0);
// Mix color with the background
color = float4(
mix(backgroundColor.r, color.r, mixRatio),
mix(backgroundColor.g, color.g, mixRatio),
mix(backgroundColor.b, color.b, mixRatio),
1.0
);
// Keep all channels within valid bounds of 0.0 and 1.0
return clamp(color, 0.0, 1.0);
}""".trimIndent()
Explanation of the Shader Code
Uniform Variables:
resolution: Provides the screen resolution.time: Tracks the elapsed time, creating a dynamic animation effect.baseColorandbackgroundColor: Define the primary and background colors for blending.
Dynamic Animation:
- The shader uses the
timevariable to create smooth, animated color waves.
Iteration Loop:
- The loop generates iterative adjustments to the UV coordinates (
uvCalc) for creating complex wave patterns.
Color Blending:
- Combines the
baseColorandbackgroundColorusing a fading curve to create the wave effect.
Clamping:
- Ensures all color values remain within valid ranges (
0.0 to 1.0) for proper rendering.
8.2 — Integration with Jetpack Compose
Our integration code will make use of the RuntimeShader API with AGSL to render a dynamic solar wave effect as a background in the Compose component.
1. Shader Modifier
The solarFlareShaderBackground function applies the shader effect or a fallback gradient, depending on the Android version.
fun Modifier.solarFlareShaderBackground(
baseColor: Color,
backgroundColor: Color,
): Modifier =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.then(SolarFlareShaderBackgroundElement(baseColor, backgroundColor))
} else {
this.then(Modifier.simpleGradient()) // Fallback for older Android versions
}
Explanation:
Fallback Handling:
- For Android versions below 13 (TIRAMISU), a simple vertical gradient (
simpleGradient()) is applied as a fallback.
Dynamic Application:
- Uses
SolarFlareShaderBackgroundElementfor devices supporting AGSL.
2. Solar Flare Shader Element
Create the SolarFlareShaderBackgroundElement class, to connect the shader to the Compose modifier system.
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private data class SolarFlareShaderBackgroundElement(
val baseColor: Color,
val backgroundColor: Color,
) : ModifierNodeElement<SolarFlairShaderBackgroundNode>() {
override fun create() = SolarFlairShaderBackgroundNode(baseColor, backgroundColor)
override fun update(node: SolarFlairShaderBackgroundNode) {
node.updateColors(baseColor, backgroundColor)
}
}
Explanation:
**createandupdate**:createinitializes the node (SolarFlairShaderBackgroundNode).updateupdates the shader colors dynamically if the colors change.
- Shader Node Implementation
And finally we define the SolarFlairShaderBackgroundNode class, it will contain the core logic for rendering the shader.
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private class SolarFlairShaderBackgroundNode(
baseColor: Color,
backgroundColor: Color,
) : DrawModifierNode, Modifier.Node() {
private val shader = RuntimeShader(SOLAR_FLAIR_SHADER)
private val shaderBrush = ShaderBrush(shader)
private val time = mutableFloatStateOf(0f)
init {
updateColors(baseColor, backgroundColor)
}
fun updateColors(baseColor: Color, backgroundColor: Color) {
shader.setColorUniform(
"baseColor",
android.graphics.Color.valueOf(
baseColor.red,
baseColor.green,
baseColor.blue,
baseColor.alpha
)
)
shader.setColorUniform(
"backgroundColor",
android.graphics.Color.valueOf(
backgroundColor.red,
backgroundColor.green,
backgroundColor.blue,
backgroundColor.alpha
)
)
}
override fun ContentDrawScope.draw() {
shader.setFloatUniform("resolution", size.width, size.height)
shader.setFloatUniform("time", time.floatValue)
drawRect(shaderBrush)
drawContent()
}
override fun onAttach() {
coroutineScope.launch {
while (isAttached) {
delay(150)
withInfiniteAnimationFrameMillis {
time.floatValue = it / 3000f // Updates time for dynamic animation
}
}
}
}
}
Explanation:
- Dynamic Time Update:
- Updates the
timevariable every 150ms to create a smooth animation effect. - Shader Parameters:
resolution: Adjust the shader to the device's screen size.baseColorandbackgroundColor: Passed as uniform variables for dynamic color blending.time: Drives the animation effect, ensuring smooth transitions over time.
4. Fallback Gradient
The simpleGradient function applies a static vertical gradient for devices without AGSL support.
fun Modifier.simpleGradient(): Modifier =
drawWithCache {
val gradientBrush = Brush.verticalGradient(listOf(Blue, PurpleGrey40, customDeepBlue))
onDrawBehind {
drawRect(gradientBrush, alpha = 1f)
}
}
Explanation:
Static Gradient:
- Displays a static gradient with three colors (
Blue,Purple,customBlue).
Compatibility:
- Ensures older devices can display a visually appealing effect.
Update the Compose Code to make use of it.
To see the result, we need to remove the backgroundand make use of the new Modifierextension function solarFlareShaderBackground in the Box component of our Card.
.solarFlareShaderBackground(customLightBlue, customLightBlue)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun CardFrontSide(
cardFlip: CardFlip,
) Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
.solarFlareShaderBackground(customLightBlue, customLightBlue),
contentAlignment = Alignment.Center,
) {
// Top half circle.
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.TopCenter))
// Card content.
CardContent(
date = Calendar.getInstance().time.toString(),
userId = cardFlip.card?.id ?: "0",
username = cardFlip.card?.username ?: "User",
userImage = cardFlip.card?.userImage ?: ""
)
// Bottom half circle.
CardHalfCircles(modifier = Modifier.align(alignment = Alignment.BottomCenter))
}
}

CardFlipFront with AGSL Shader applied to it.
Repeat the same strategy to CardFlipBack component, by updating the code to use solarFlareShaderBackground instead of background .
@Composable
fun CardBackSide() {
Column(
Modifier
.fillMaxWidth()
.height(500.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.solarFlareShaderBackground(customDeepBlue, customLightBlue),
contentAlignment = Alignment.Center,
) {
...
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.solarFlareShaderBackground(customLightBlue, Color.White),
contentAlignment = Alignment.TopCenter
)
....

If the device runs an Android version below the Tiramisu SDK, we have defined in the code that it should apply a simple gradient effect as our fallback option.

Final Result.


Thanks for checking this article!
See you soon!
메타데이터
- post_id
- 0fcdd6f552b2
- slug
- tutorial-android-agsl-enhance-your-compose-component-with-shaders-0fcdd6f552b2
- url
- https://medium.com/@spparks_/tutorial-android-agsl-enhance-your-compose-component-with-shaders-0fcdd6f552b2
- canonical_url
- https://medium.com/@spparks_/tutorial-android-agsl-enhance-your-compose-component-with-shaders-0fcdd6f552b2
- author_url
- https://medium.com/@spparks_
- status
- ok
- fetched_at
- 2026-07-21 12:47:27