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 滚轮选择器效果

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui