Try High Quality UI Blocks built with Kotlin & Jetpack Compose

Ready-to-use components for Jetpack Compose and Compose Multiplatform. Copy paste into your apps.

Expandable Card

/**
 *  Requires the following dependencies. Add them to app/build.gradle.kts
 *
 *  dependencies {
 *      implementation("com.composables:compose-uri-painter:1.0.2")
 *   }
 *   
 */

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.composables.uripainter.rememberUriPainter

@Composable
fun CardExpandable() {
    data class Review(val stars: Float, val reviewer: String, val timestamp: String, val summary: String)

    val reviews = listOf(
        Review(
            stars = 5f,
            reviewer = "John",
            timestamp = "2 hours ago",
            summary = "Best coffee in town. I love the atmosphere and the staff is super friendly."
        ),
        Review(
            stars = 4.5f,
            reviewer = "Cassidy",
            timestamp = "1 day ago",
            summary = "Great place to unwind. The cappuccino was excellent, and the pastries were delicious."
        ),
        Review(
            stars = 3.5f,
            reviewer = "James",
            timestamp = "3 days ago",
            summary = "Decent coffee, but a bit crowded during peak hours. Could use more seating space."
        ),
        Review(
            stars = 4f,
            reviewer = "Cassidy",
            timestamp = "5 days ago",
            summary = "Nice little coffee spot. I enjoyed the latte and the artsy decor."
        ),
        Review(
            stars = 5f,
            reviewer = "Rony",
            timestamp = "1 week ago",
            summary = "Charming café with a Parisian vibe. The espresso here is top-notch."
        ),
        Review(
            stars = 3.5f,
            reviewer = "Fraklin",
            timestamp = "2 weeks ago",
            summary = "Average coffee, but the location is convenient for a quick stop."
        )
    )

    @Composable
    fun RatingBar(
        rating: Float,
        maxRating: Int,
        modifier: Modifier = Modifier,
        fillColor: Color = Color(0xFFFFC107),
        emptyColor: Color = Color(0xFFEEEEEE)
    ) {
        val FirstHalf = object : Shape {
            override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
                return Outline.Rectangle(Rect(0f, 0f, size.width / 2, size.height))
            }
        }

        // offset matches the padding of the Star Icon
        Row(modifier.offset(x = (-2).dp)) {
            repeat(maxRating) { i ->
                Box(Modifier.size(24.dp)) {
                    val lastFullIndex = (rating - 1).toInt()
                    Icon(
                        imageVector = Icons.Rounded.Star,
                        contentDescription = null,
                        tint = emptyColor,
                        modifier = Modifier.matchParentSize()
                    )
                    when {
                        i <= lastFullIndex -> {
                            Icon(
                                imageVector = Icons.Rounded.Star,
                                contentDescription = null,
                                tint = fillColor,
                                modifier = Modifier.matchParentSize()
                            )
                        }

                        i == lastFullIndex + 1 -> {
                            Icon(
                                imageVector = Icons.Rounded.Star,
                                contentDescription = null,
                                tint = fillColor,
                                modifier = Modifier.matchParentSize().clip(FirstHalf)
                            )
                        }
                    }
                }
            }
        }
    }

    OutlinedCard {
        var expanded by remember { mutableStateOf(false) }
        val degrees by animateFloatAsState(if (expanded) -90f else 90f)

        Column(Modifier.width(380.dp)) {
            Image(
                rememberUriPainter("https://images.unsplash.com/photo-1491147334573-44cbb4602074?w=320"),
                modifier = Modifier.clip(CardDefaults.outlinedShape).aspectRatio(16 / 9f),
                contentScale = ContentScale.Crop,
                contentDescription = null
            )
            Column(Modifier.padding(start = 16.dp, end = 16.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text("Cafe de Paris", style = MaterialTheme.typography.titleLarge)
                    Spacer(Modifier.weight(1f))
                    IconButton(onClick = { expanded = expanded.not() }) {
                        Icon(
                            imageVector = Icons.Filled.ChevronRight,
                            contentDescription = if (expanded) "Hide details" else "Show more details",
                            modifier = Modifier.rotate(degrees)
                        )
                    }
                }
                Row(verticalAlignment = Alignment.CenterVertically) {
                    RatingBar(rating = 4.5f, maxRating = 5)
                    Spacer(Modifier.width(8.dp))
                    Text("4.5", style = MaterialTheme.typography.bodyMedium)
                    Spacer(Modifier.width(4.dp))
                    Text("(${1280})", style = MaterialTheme.typography.bodyMedium)
                }
                Spacer(Modifier.height(16.dp))
                Box {
                    this@Column.AnimatedVisibility(visible = expanded) {
                        LazyColumn(
                            verticalArrangement = Arrangement.spacedBy(16.dp),
                            modifier = Modifier.height(190.dp),
                            contentPadding = PaddingValues(vertical = 8.dp)
                        ) {
                            item { Text("Reviews (${reviews.size})") }
                            reviews.forEach { review ->
                                val rating = review.stars
                                val reviewer = review.reviewer
                                val timestamp = review.timestamp
                                val summary = review.summary

                                item {
                                    Column {
                                        Text(text = reviewer, style = MaterialTheme.typography.bodyLarge)
                                        Spacer(Modifier.height(8.dp))
                                        Row(
                                            verticalAlignment = Alignment.CenterVertically,
                                            horizontalArrangement = Arrangement.spacedBy(8.dp)
                                        ) {
                                            RatingBar(rating = rating, maxRating = 5)
                                            Text(text = timestamp, style = MaterialTheme.typography.bodyMedium)
                                        }
                                        Spacer(Modifier.height(16.dp))
                                        Text(text = summary, style = MaterialTheme.typography.bodyMedium)
                                    }
                                }
                            }
                        }
                    }
                    this@Column.AnimatedVisibility(visible = expanded) {
                        HorizontalDivider()
                    }
                }
            }
        }
    }
}

Be notified when we ship more UI Blocks

Subscribe to get updates on when we ship new components.

MusicPlayer with Album Cover and Seekbar

/**
 *  Requires the following dependencies. Add them to app/build.gradle.kts
 *
 *  dependencies {
 *      implementation("com.composables:compose-uri-painter:1.0.2")
 *   }
 *   
 */

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.uripainter.rememberUriPainter
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.minutes


@Composable
fun MusicPlayerWithAlbumCoverAndSeekbar() {
    var isPlaying by remember { mutableStateOf(false) }
    var currentTimestamp by remember { mutableLongStateOf(0L) }
    val totalDuration = 3.2.minutes.inWholeMilliseconds

    Column(
        modifier = Modifier.widthIn(max = 300.dp).padding(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = rememberUriPainter("https://images.unsplash.com/photo-1668605335684-c97ce92cbd76?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=512&q=80"),
            modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(MaterialTheme.shapes.large)
                .background(MaterialTheme.colorScheme.secondaryContainer),
            contentScale = ContentScale.Crop,
            contentDescription = null
        )
        Column(Modifier.padding(vertical = 12.dp)) {
            Box(contentAlignment = Alignment.Center) {
                Text(
                    text = "\tUnraveling the Human Psyche: Exploring the Depths of Consciousness",
                    modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(58.dp))
                        .align(Alignment.Center),
                    style = MaterialTheme.typography.titleLarge,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
                Box(
                    Modifier.align(Alignment.CenterStart)
                        .height(MaterialTheme.typography.titleLarge.fontSize.value.dp + 8.dp).background(
                            Brush.horizontalGradient(
                                listOf(
                                    MaterialTheme.colorScheme.background,
                                    Color.Transparent
                                )
                            )
                        ).width(24.dp)
                )
                Box(
                    Modifier.align(Alignment.CenterEnd)
                        .height(MaterialTheme.typography.titleLarge.fontSize.value.dp + 8.dp).background(
                            Brush.horizontalGradient(
                                listOf(
                                    Color.Transparent,
                                    MaterialTheme.colorScheme.background
                                )
                            )
                        ).width(24.dp)
                )
            }
            Text(
                text = "Mind Matters",
                style = MaterialTheme.typography.bodyLarge,
                modifier = Modifier.padding(top = 8.dp),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }

        val scrubberInteraction = remember { MutableInteractionSource() }
        val isScrubbing by scrubberInteraction.collectIsDraggedAsState()
        val progress by animateFloatAsState(
            targetValue = currentTimestamp.toFloat() / totalDuration.toFloat(),
            animationSpec = if (isScrubbing) spring() else tween(200),
        )

        LaunchedEffect(Unit) {
            while (true) {
                delay(1000)
                if (isPlaying && isScrubbing.not()) {
                    currentTimestamp = (currentTimestamp + 1000)
                }
            }
        }
        Slider(
            interactionSource = scrubberInteraction,
            value = progress,
            onValueChangeFinished = {},
            onValueChange = { // convert % to currentTimestamp
                currentTimestamp = (it * totalDuration).toLong()
            })
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(24.dp),
            modifier = Modifier.padding(16.dp)
        ) {
            IconButton(onClick = {/*TODO Skip to previous media item*/ }) {
                Icon(
                    imageVector = Icons.Filled.SkipPrevious,
                    contentDescription = "Previous",
                    modifier = Modifier.size(36.dp),
                    tint = MaterialTheme.colorScheme.primary
                )
            }
            FilledIconButton(onClick = {/*TODO toggle playback*/
                isPlaying = !isPlaying
            }, modifier = Modifier.size(64.dp), shape = CircleShape) {
                if (isPlaying) {
                    Icon(
                        imageVector = Icons.Filled.Pause,
                        contentDescription = "Pause",
                        modifier = Modifier.size(48.dp)
                    )
                } else {
                    Icon(
                        imageVector = Icons.Filled.PlayArrow,
                        contentDescription = "Play",
                        modifier = Modifier.size(48.dp)
                    )
                }
            }
            IconButton(onClick = {/*TODO Skip to previous media item*/ }) {
                Icon(
                    imageVector = Icons.Filled.SkipNext,
                    contentDescription = "Next",
                    modifier = Modifier.size(36.dp),
                    tint = MaterialTheme.colorScheme.primary
                )
            }
        }
    }
}

Be notified when we ship more UI Blocks

Subscribe to get updates on when we ship new components.

List with swipe to dismiss

/**
 *  Requires the following dependencies. Add them to app/build.gradle.kts
 *
 *  dependencies {
 *      implementation("com.composables:compose-uri-painter:1.0.2")
 *   }
 *   
 */

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Archive
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.uripainter.rememberUriPainter
import kotlin.random.Random

@Composable
fun ListViewWithSwipeToDismiss() {
    data class Message(
        val id: Int = Random.nextInt(),
        val displayName: String,
        val content: String,
        val avatar: String
    )

    val allMessages = listOf(
        Message(
            displayName = "Ava Johnson",
            content = "Hey! Just wanted to let you know that I got the tickets for the concert. Can't wait to go together!",
            avatar = "https://images.unsplash.com/photo-1544005313-94ddf0286df2?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1920&q=80"
        ),
        Message(
            displayName = "Adrian Thompson",
            content = "Happy birthday! Wishing you an amazing day filled with joy, laughter, and lots of cake!",
            avatar = "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1920&q=80"
        ),
        Message(
            displayName = "Amelia Rodriguez",
            content = "Meeting at 3 pm sounds good. Let's discuss the agenda and prepare some ideas beforehand.",
            avatar = "https://images.unsplash.com/photo-1580489944761-15a19d654956?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1920&q=80"
        ),
        Message(
            displayName = "Aaron Smith",
            content = "Congratulations on your new job! You've worked hard for this opportunity. Proud of you!",
            avatar = "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1920&q=80"
        ),
        Message(
            displayName = "Alice Bennett",
            content = "I miss you! It's been too long since we last caught up. Let's plan a coffee date soon!",
            avatar = "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1920&q=80"
        )
    )
    val items = remember { mutableStateListOf(*allMessages.toTypedArray()) }

    @Composable
    fun ListItem(
        onDismiss: () -> Unit,
        photoUrl: String,
        title: String,
        content: String,
        modifier: Modifier = Modifier
    ) {
        val dismissState = rememberSwipeToDismissBoxState()
        val backgroundAlpha by animateFloatAsState(
            targetValue = if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) 0f else 1f,
            finishedListener = {
                onDismiss()
            })
        val cornerRadius by animateDpAsState(targetValue = if (dismissState.dismissDirection != SwipeToDismissBoxValue.Settled) 12.dp else 0.dp)

        SwipeToDismissBox(modifier = modifier, state = dismissState, backgroundContent = {
            val alignment = when (dismissState.dismissDirection) {
                SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
                SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd
                SwipeToDismissBoxValue.Settled -> Alignment.Center
            }
            Box(
                modifier = Modifier.alpha(backgroundAlpha).fillMaxSize()
                    .background(MaterialTheme.colorScheme.errorContainer).padding(16.dp), contentAlignment = alignment
            ) {
                Icon(
                    imageVector = Icons.Outlined.Archive,
                    contentDescription = "Archive",
                    modifier = Modifier.size(24.dp)
                )
            }
        }) {
            Surface(onClick = { /* TODO */ }, shape = RoundedCornerShape(cornerRadius)) {
                Row(
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                    modifier = Modifier.fillMaxWidth().padding(16.dp)
                ) {
                    Image(
                        rememberUriPainter(photoUrl),
                        modifier = Modifier.size(58.dp).clip(CircleShape),
                        contentScale = ContentScale.Crop,
                        contentDescription = null
                    )
                    Column(Modifier.weight(1f)) {
                        Row(
                            horizontalArrangement = Arrangement.SpaceBetween,
                            modifier = Modifier.fillMaxWidth(),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Text(
                                text = title,
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis,
                                fontWeight = FontWeight.SemiBold
                            )
                            Text(text = "1d ago", style = MaterialTheme.typography.labelLarge)
                        }
                        Spacer(Modifier.height(4.dp))
                        Text(
                            text = content,
                            style = MaterialTheme.typography.bodyLarge,
                            maxLines = 2,
                            overflow = TextOverflow.Ellipsis
                        )
                    }
                }
            }
        }
    }

    Box(
        modifier = Modifier.padding(24.dp).border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.large)
            .clip(MaterialTheme.shapes.large)
    ) {
        AnimatedVisibility(
            visible = items.isEmpty(),
            modifier = Modifier.align(Alignment.Center).fillMaxSize().padding(top = 20.dp),
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text("(°-° ;)", style = MaterialTheme.typography.titleLarge, fontSize = 58.sp)
                Spacer(Modifier.height(4.dp))
                Text("No messages left to swipe away", style = MaterialTheme.typography.titleMedium)
                Spacer(Modifier.height(8.dp))

                Button(onClick = {
                    val newMessages = allMessages.map { message ->
                        message.copy(id = Random.nextInt())
                    }
                    items.addAll(newMessages)
                }) {
                    Text("Bring them back")
                }
            }
        }
        AnimatedVisibility(items.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
            LazyColumn(
                modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()
                    .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.large)
                    .clip(MaterialTheme.shapes.large).background(MaterialTheme.colorScheme.background)
            ) {
                items(items, key = { item -> item.id }) { item ->
                    ListItem(
                        photoUrl = item.avatar,
                        title = item.displayName,
                        content = item.content,
                        onDismiss = {
                            items.remove(item)
                        })
                }
            }
        }
    }
}

Be notified when we ship more UI Blocks

Subscribe to get updates on when we ship new components.

Vertical Parallax Effect

/**
 *  Requires the following dependencies. Add them to app/build.gradle.kts
 *
 *  dependencies {
 *      implementation("com.composables:compose-uri-painter:1.0.2")
 *   }
 *   
 */

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import com.composables.uripainter.rememberUriPainter

@Composable
fun VerticalParallaxEffect() {
    data class ArticleSummary(val title: String, val coverUrl: String)

    val articles = listOf(
        ArticleSummary(
            title = "The Future of Sustainable Architecture",
            coverUrl = "https://images.unsplash.com/photo-1448630360428-65456885c650?q=80&w=1920"
        ),
        ArticleSummary(
            title = "The Art of Minimalist Design",
            coverUrl = "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Coffee Culture Around the World",
            coverUrl = "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?q=80&w=1920"
        ),
        ArticleSummary(
            title = "The Science of Sleep and Productivity",
            coverUrl = "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Ocean Conservation: Protecting Marine Life",
            coverUrl = "https://images.unsplash.com/photo-1544551763-46a013bb70d5?q=80&w=1920"
        ),
        ArticleSummary(
            title = "The Rise of Remote Work Culture",
            coverUrl = "https://images.unsplash.com/photo-1521737604893-d14cc237f11d?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Ancient Forests: Nature's Timeless Beauty",
            coverUrl = "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Urban Gardening: Green Cities Initiative",
            coverUrl = "https://images.unsplash.com/photo-1416879595882-3373a0480b5b?q=80&w=1920"
        ),
        ArticleSummary(
            title = "The Art of Japanese Tea Ceremony",
            coverUrl = "https://images.unsplash.com/photo-1544787219-7f47ccb76574?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Northern Lights: Arctic Wonder",
            coverUrl = "https://images.unsplash.com/photo-1531366936337-7c912a4589a7?q=80&w=1920"
        ),
        ArticleSummary(
            title = "Desert Landscapes: Beauty in Aridity",
            coverUrl = "https://images.unsplash.com/photo-1509316785289-025f5b846b35?q=80&w=1920"
        ),
    )

    fun Modifier.verticalParallax(listState: LazyListState, index: Int, rate: Float = 2f) = this.then(
        Modifier.layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)
            val itemOffset = listState.layoutInfo.visibleItemsInfo.find { it.index == index }?.offset ?: 0
            val offsetY = itemOffset / rate
            layout(placeable.width, placeable.height) {
                placeable.place(0, offsetY.toInt())
            }
        }
    )

    val state = rememberLazyListState()
    LazyColumn(
        state = state,
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
        contentPadding = PaddingValues(24.dp),
    ) {
        articles.forEachIndexed { i, article ->
            item {
                Card(Modifier.height(280.dp)) {
                    Box(Modifier.fillMaxSize()) {
                        Image(
                            painter = rememberUriPainter(article.coverUrl),
                            contentDescription = article.title,
                            contentScale = ContentScale.Crop,
                            modifier = Modifier
                                .fillMaxWidth()
                                .wrapContentHeight(unbounded = true)
                                .verticalParallax(state, rate = 5f, index = i)
                        )

                        Box(
                            Modifier
                                .align(Alignment.BottomStart)
                                .fillMaxWidth()
                                .background(
                                    Brush.verticalGradient(
                                        colors = listOf(
                                            Color.Transparent,
                                            Color.Black.copy(alpha = 0.2f),
                                            Color.Black.copy(alpha = 0.4f)
                                        )
                                    )
                                )
                                .padding(16.dp)
                        ) {
                            Text(article.title, color = Color.White)
                        }
                    }
                }
            }
        }
    }
}

Be notified when we ship more UI Blocks

Subscribe to get updates on when we ship new components.

Explore other Jetpack Compose Blocks