Jetpack Compose 滚轮选择器

文章目录


前言

此组件使用LazyColumn利用LazyListState相关属性实现滚轮选择器。支持设置默认选择值、滚动选中回调、循环滚动、自定义Item布局、自定义选中项覆盖层等

一、自定义Jetpack Compose 滚轮选择器

kotlin 复制代码
/**
 * 滚轮选择器组件
 *
 * @param modifier 修饰符
 * @param items 要显示的项目列表
 * @param selectedIndex 初始选中的项目索引
 * @param onItemSelected 选中项目时的回调函数
 * @param itemHeight 每个项目的高度
 * @param itemSpace 项目之间的间距
 * @param extraRow 额外的行数(在选中项上下显示)
 * @param isLooping 是否启用循环滚动
 * @param maxTiltAngle 滚轮倾斜的最大角度 [-360f,360f] 不应用倾斜传值0f
 * @param minAlpha 最小透明度比例 [0f,1f] 不应用透明度传值1f
 * @param minScaleX 最小缩放比例 [0f,1f] 不应用缩放传值1f
 * @param selectOverlay 覆盖在滚轮上的内容组件
 * @param itemContent 自定义项目内容的显示方式
 *
 */
@Composable
internal fun <T> WheelPicker(
    modifier: Modifier = Modifier,
    items: List<T>,
    selectedIndex: Int = 0,
    onItemSelected: (index: Int, item: T) -> Unit = { _, _ -> },
    itemHeight: Dp = 50.dp,
    itemSpace: Dp = 20.dp,
    extraRow: Int = 1,
    isLooping: Boolean = false,
    maxTiltAngle: Float = 0f,
    minAlpha: Float = 1f,
    minScaleX: Float = 1f,
    selectOverlay: @Composable () -> Unit = {},
    itemContent: @Composable (index: Int, item: T, isItemSelected: Boolean) -> Unit
) {
    // 当前选中的Item索引
    var selectedItemIndex by remember { mutableIntStateOf(selectedIndex) }

    val listState = if (!isLooping) {
        // 非循环滚动
        rememberLazyListState(selectedItemIndex)
    } else {
        // 循环滚动
        // 居中的参考点
        val initialIndex = Int.MAX_VALUE / 2
        // 目标索引
        val targetIndex = selectedItemIndex - extraRow
        // 计算上边界:离initialIndex最近的且能被size整除的位置加目标索引
        val upperLimit = (initialIndex / items.size) * items.size + targetIndex
        // 计算下边界
        val lowerLimit = upperLimit + items.size
        // 计算初始索引:取离initialIndex最近的边界
        val loopInitialIndex = if ((initialIndex - upperLimit) <= (lowerLimit - initialIndex)) {
            upperLimit
        } else {
            lowerLimit
        }
        rememberLazyListState(loopInitialIndex)
    }
    // 计算滚轮总高度
    val wheelHeight = (itemHeight * (extraRow * 2)) + (itemSpace * (extraRow * 2 + 2)) + itemHeight
    // 计算最大偏移量(Item高度 + 间距)
    val maxOffset = with(LocalDensity.current) { itemHeight.toPx() + itemSpace.toPx() }
    //可见首项索引
    val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
    //可见首项偏移
    val firstVisibleItemScrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
    // 滚动时更新选中Item
    LaunchedEffect(firstVisibleItemScrollOffset) {
        val selected = if (isLooping) {
            // 循环滚动:计算当前选中项,考虑循环逻辑
            (firstVisibleItemIndex + if (firstVisibleItemScrollOffset > maxOffset / 2) extraRow + 1 else extraRow) % items.size
        } else {
            // 非循环滚动:计算当前选中项
            (firstVisibleItemIndex + if (firstVisibleItemScrollOffset > maxOffset / 2) 1 else 0) % items.size
        }
        onItemSelected(selected, items[selected])
        selectedItemIndex = selected
    }
    //是否滚动中
    val isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
    // 是否允许自动滚动
    var allowAutoScrolling by remember { mutableStateOf(true) }
    // 滚动停止时自动对齐到最近的Item
    LaunchedEffect(isScrolling) {
        if (!isScrolling) {
            if (allowAutoScrolling) {
                listState.animateScrollToItem(firstVisibleItemIndex + if (firstVisibleItemScrollOffset > maxOffset / 2) 1 else 0)
                allowAutoScrolling = false
            } else {
                allowAutoScrolling = true
            }
        }
    }
    // 绘制滚轮容器
    Box(
        modifier = modifier.height(wheelHeight),
        contentAlignment = Alignment.Center
    ) {
        //选中项覆盖层
        selectOverlay()
        // 可滚动列表
        LazyColumn(
            modifier = Modifier.fillMaxWidth(),
            state = listState,
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(itemSpace),
            contentPadding = PaddingValues(
                top = itemSpace,
                // 非循环滚动时底部需要额外的填充来保持选中项在中心
                bottom = if (isLooping) itemSpace else (itemSpace * (extraRow + 1)) + (itemHeight * extraRow)
            )
        ) {
            // 非循环滚动时在顶部添加空白间隔以保持选中项在中心
            if (!isLooping) {
                for (x in 1..extraRow) {
                    item {
                        Spacer(modifier = Modifier.height(itemHeight))
                    }
                }
            }
            // 绘制列表项
            val itemCount = if (isLooping) (Int.MAX_VALUE) else items.size
            items(itemCount) { index ->
                val itemIndex = index % items.size
                val item = items[itemIndex]
                val isItemSelected = selectedItemIndex == itemIndex
                val countIndex = index % itemCount
                val actualIndex = if (isLooping) countIndex - extraRow else itemIndex
                // 计算相对于选中项的偏移量,用于倾斜效果
                val offsetFromCenter = if (isLooping) {
                    val distance = (index - (firstVisibleItemIndex + extraRow)) % items.size
                    // 处理负数情况
                    if (distance > items.size / 2) distance - items.size
                    else if (distance < -items.size / 2) distance + items.size
                    else distance
                } else {
                    index - (firstVisibleItemIndex + if (firstVisibleItemScrollOffset > maxOffset / 2) 1 else 0)
                }
                Box(
                    modifier = Modifier
                        .height(itemHeight)
                        .graphicsLayer {
                            // 根据距离选中项的远近计算倾斜因子 [-1, 1]
                            val tiltFactor = (offsetFromCenter / (extraRow + 1).toFloat()).coerceIn(-1f, 1f)
                            rotationX = tiltFactor * maxTiltAngle
                            // 透明度
                            alpha = (1f - abs(tiltFactor) * (1f - minAlpha)).coerceIn(minAlpha, 1f)
                            // X轴缩放
                            scaleX = (1f - abs(tiltFactor) * (1f - minScaleX)).coerceIn(minScaleX, 1f)
                        },
                    contentAlignment = Alignment.Center,
                ) {
                    itemContent(itemIndex, item, isItemSelected)
                }
            }
        }
    }
}

二、使用

kotlin 复制代码
        /**
         * 循环滚动数字选择示例
         */
        WheelPicker(
            modifier = Modifier
                .fillMaxWidth()
                .background(color = Color.Gray.copy(alpha = 0.1f)),
            items = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
            selectedIndex = 3,
            itemHeight = 40.dp,
            itemSpace = 10.dp,
            extraRow = 1,
            isLooping = true,
            maxTiltAngle = 0f,
            minAlpha = 1f,
            minScaleX = 1f,
            onItemSelected = { index, item ->
                ToastUtils.showShort("数字: index:$index,item:$item")
            },
            selectOverlay = {
                Box(
                    modifier = Modifier
                        .padding(start = 16.dp, end = 16.dp)
                        .fillMaxWidth()
                        .height(40.dp)
                        .border(width = 1.dp, color = Color.Red, shape = RoundedCornerShape(10.dp))
                )
            },
            itemContent = { index, item, isItemSelected ->
                Text(
                    text = item.toString(),
                    fontSize = if (isItemSelected) 28.sp else 18.sp,
                    color = if (isItemSelected) Color.Red else Color.Black
                )
            }
        )

		//------------------------------------------------------------

        /**
         * 城市选择示例
         */
        WheelPicker(
            modifier = Modifier
                .fillMaxWidth()
                .background(color = Color.Gray.copy(alpha = 0.1f)),
            items = listOf("北京", "上海", "深圳", "重庆", "长沙", "武汉", "南昌", "青海", "邵阳", "衡阳"),
            selectedIndex = 0,
            itemHeight = 50.dp,
            itemSpace = 10.dp,
            extraRow = 2,
            maxTiltAngle = -45f,
            minAlpha = 0.5f,
            minScaleX = 0.5f,
            isLooping = false,
            onItemSelected = { index, item ->
                ToastUtils.showShort("城市: index:$index,item:$item")
            },
            selectOverlay = {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(50.dp)
                        .background(color = Color.Blue.copy(alpha = 0.2f))
                )
            },
            itemContent = { index, item, isItemSelected ->
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    //...其它控件
                    Text(
                        text = item.toString(),
                        fontSize = 22.sp,
                        color = if (isItemSelected) Color.Blue else Color.Black
                    )
                }
            }
        )

三、效果

Compose 滚轮选择器效果

相关推荐
金融RPA机器人丨实在智能5 小时前
Android Studio开发App项目进入AI深水区:实在智能Agent引领无代码交互革命
android·人工智能·ai·android studio
科技块儿5 小时前
利用IP查询在智慧城市交通信号系统中的应用探索
android·tcp/ip·智慧城市
独行soc6 小时前
2026年渗透测试面试题总结-18(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
王码码20356 小时前
Flutter for OpenHarmony 实战之基础组件:第二十七篇 BottomSheet — 动态底部弹窗与底部栏菜单
android·flutter·harmonyos
2501_915106326 小时前
app 上架过程,安装包准备、证书与描述文件管理、安装测试、上传
android·ios·小程序·https·uni-app·iphone·webview
vistaup7 小时前
OKHTTP 默认构建包含 android 4.4 的TLS 1.2 以及设备时间不对兼容
android·okhttp
常利兵7 小时前
ButterKnife在Android 35 + Gradle 8.+环境下的适配困境与现代化迁移指南
android
撩得Android一次心动7 小时前
Android LiveData 全面解析:使用Java构建响应式UI【源码篇】
android·java·android jetpack·livedata
熊猫钓鱼>_>7 小时前
移动端开发技术选型报告:三足鼎立时代的开发者指南(2026年2月)
android·人工智能·ios·app·鸿蒙·cpu·移动端
Rainman博17 小时前
WMS-窗口relayout&FinishDrawing
android