Skip to content

sonms/Velvet-Compose

Repository files navigation

🎡 Velvet-Compose

License API

Smooth & beautiful Jetpack Compose component library inspired by iOS.
Velvet provides silky-smooth UI components with full customization support.


📦 Components

Component Maven Central Description
🎡 WheelPicker Maven Central iOS-style 3D wheel picker with infinite scroll
RatingBar Maven Central Customizable rating bar with spring animation & haptic feedback

🚀 Getting Started

Gradle

dependencies {
    implementation("io.github.sonms:wheelpicker:0.0.1")
    implementation("io.github.sonms:ratingbar:0.0.1")
}

🎡 WheelPicker

iOS-style 3D wheel picker for Jetpack Compose.
Supports infinite scrolling, 3D graphic layers, and full style customization.

📸 Preview

Vertical Horizontal

Basic Usage

val items = remember { (1..12).map { it.toString().padStart(2, '0') } }
val state = rememberWheelPickerState(initialIndex = 0)

VerticalWheelPicker(
    items = items,
    state = state,
    visibleItemCount = 5,
    infinite = true,
    onItemSelected = { index, item -> },
) { item, isSelected ->
    Text(
        text = item,
        fontSize = if (isSelected) 20.sp else 16.sp,
        fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
        color = if (isSelected) Color.Black else Color.Gray,
    )
}

Time Picker Sample (AM/PM + Hour + Minute)

@Composable
fun TimePickerSample() {
    val amPmItems = remember { listOf("AM", "PM") }
    val hourItems = remember { (1..12).map { it.toString().padStart(2, '0') } }
    val minuteItems = remember { (0..59).map { it.toString().padStart(2, '0') } }

    val amPmState = rememberWheelPickerState(initialIndex = 0)
    val hourState = rememberWheelPickerState(initialIndex = 0)
    val minuteState = rememberWheelPickerState(initialIndex = 0)

    val itemHeight = 48.dp

    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        // Custom selector spanning all three pickers
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp)
                .height(itemHeight)
                .background(
                    color = Color.Gray.copy(alpha = 0.15f),
                    shape = RoundedCornerShape(12.dp),
                )
        )

        Row(
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            VerticalWheelPicker(
                items = amPmItems,
                state = amPmState,
                modifier = Modifier.width(80.dp),
                itemHeight = itemHeight,
                visibleItemCount = 5,
                infinite = false,
                style = WheelPickerDefaults.style(
                    selector = WheelPickerDefaults.selectorStyle(
                        background = Color.Transparent,
                        showDivider = false,
                    ),
                ),
            ) { item, isSelected ->
                Text(
                    text = item,
                    fontSize = if (isSelected) 20.sp else 16.sp,
                    color = if (isSelected) Color.Black else Color.Gray,
                )
            }

            VerticalWheelPicker(
                items = hourItems,
                state = hourState,
                modifier = Modifier.width(80.dp),
                itemHeight = itemHeight,
                visibleItemCount = 5,
                infinite = true,
                style = WheelPickerDefaults.style(
                    selector = WheelPickerDefaults.selectorStyle(
                        background = Color.Transparent,
                        showDivider = false,
                    ),
                ),
            ) { item, isSelected ->
                Text(
                    text = item,
                    fontSize = if (isSelected) 20.sp else 16.sp,
                    color = if (isSelected) Color.Black else Color.Gray,
                )
            }

            VerticalWheelPicker(
                items = minuteItems,
                state = minuteState,
                modifier = Modifier.width(80.dp),
                itemHeight = itemHeight,
                visibleItemCount = 5,
                infinite = true,
                style = WheelPickerDefaults.style(
                    selector = WheelPickerDefaults.selectorStyle(
                        background = Color.Transparent,
                        showDivider = false,
                    ),
                ),
            ) { item, isSelected ->
                Text(
                    text = item,
                    fontSize = if (isSelected) 20.sp else 16.sp,
                    color = if (isSelected) Color.Black else Color.Gray,
                )
            }
        }
    }
}

Customization

VerticalWheelPicker(
    items = items,
    style = WheelPickerDefaults.style(
        selector = WheelPickerDefaults.selectorStyle(
            background = Color.Gray.copy(alpha = 0.15f),
            shape = RoundedCornerShape(12.dp),
            showDivider = true,
            dividerColor = Color.Gray,
            dividerThickness = 1.dp,
        ),
        fade = WheelPickerDefaults.fadeStyle(
            fraction = 0.3f,
            enabled = true,
        ),
        transform = WheelPickerDefaults.transformStyle(
            rotationEnabled = true,
            maxRotationDegree = 30f,
            scaleEnabled = true,
            minScale = 0.85f,
            alphaEnabled = true,
            minAlpha = 0.7f,
        ),
    ),
) { item, isSelected -> }

Selector Customization

1. Draw a custom Box externally

Box(
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 16.dp)
        .height(itemHeight)
        .background(
            color = Color.Gray.copy(alpha = 0.15f),
            shape = RoundedCornerShape(12.dp),
        )
)

2. Use the built-in selector via WheelPickerDefaults.selectorStyle

VerticalWheelPicker(
    style = WheelPickerDefaults.style(
        selector = WheelPickerDefaults.selectorStyle(
            background = Color.Gray.copy(alpha = 0.15f),
            shape = RoundedCornerShape(12.dp),
            showDivider = true,
            dividerColor = Color.Gray,
            dividerThickness = 1.dp,
        ),
    ),
) { item, isSelected -> }

State Control

val state = rememberWheelPickerState(initialIndex = 0)

val currentIndex = state.currentIndex

LaunchedEffect(Unit) {
    state.scrollToIndex(3)
    state.animateScrollToIndex(3)
}

API Reference

VerticalWheelPicker

Parameter Type Default Description
items List<T> required List of items to display
modifier Modifier Modifier Modifier
state WheelPickerState rememberWheelPickerState() State holder
itemHeight Dp 48.dp Height of each item
visibleItemCount Int 5 Number of visible items (odd recommended)
infinite Boolean true Enable infinite scrolling
style WheelPickerStyle WheelPickerDefaults.style() Style configuration
onItemSelected (Int, T) -> Unit {} Callback when item is settled
itemContent @Composable (T, Boolean) -> Unit required Item UI slot

HorizontalWheelPicker

Same as VerticalWheelPicker but with itemWidth instead of itemHeight.

WheelPickerState

Property / Function Description
currentIndex Currently selected item index
isScrollInProgress Whether scrolling is in progress
scrollToIndex(index) Scroll to index without animation
animateScrollToIndex(index) Scroll to index with animation

⭐ RatingBar

Highly customizable RatingBar for Jetpack Compose.
Features spring animation, haptic feedback, half-step support, and custom icon slot API.

📸 Preview

Default Star Custom Icon

Basic Usage

// Read-only
RatingBar(
    rating = 3.5f,
)

// Interactive
var rating by remember { mutableStateOf(3.5f) }
RatingBar(
    rating = rating,
    onRatingChanged = { rating = it },
)

Custom Icon (Slot API)

RatingBar(
    rating = 3.5f,
    onRatingChanged = { rating = it },
) { index, fraction ->
    Icon(
        imageVector = if (fraction > 0f) Icons.Filled.Favorite
                      else Icons.Outlined.FavoriteBorder,
        tint = if (fraction > 0f) Color.Red else Color.Gray,
        contentDescription = null,
    )
}

Customization

RatingBar(
    rating = 3.5f,
    maxRating = 5,
    stepSize = StepSize.HALF,
    style = RatingBarDefaults.style(
        filledColor = Color.Yellow,
        emptyColor = Color.Gray,
        itemSize = 32.dp,
        itemSpacing = 4.dp,
        // Spring animation (null to disable)
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow,
        ),
        // Haptic feedback on/off
        hapticFeedbackEnabled = true,
    ),
    onRatingChanged = { rating = it },
)

API Reference

RatingBar

Parameter Type Default Description
rating Float required Current rating value (0f ~ maxRating)
modifier Modifier Modifier Modifier
maxRating Int 5 Maximum number of items
stepSize StepSize StepSize.HALF Step size (FULL or HALF)
style RatingBarStyle RatingBarDefaults.style() Style configuration
onRatingChanged ((Float) -> Unit)? null Callback when rating changes. null for read-only
itemContent @Composable (Int, Float) -> Unit - Custom icon slot (optional)

StepSize

Value Description
StepSize.FULL Select in increments of 1.0
StepSize.HALF Select in increments of 0.5

📄 License

Copyright 2026 sonms

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Smooth & beautiful Jetpack Compose component library inspired by iOS 🎡⭐ WheelPicker with infinite scroll & 3D layers · RatingBar with spring animation & haptic feedback

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages