尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android Compose图片星星评分组件自研:从状态设计到拖拽交互

Android Compose图片星星评分组件自研:从状态设计到拖拽交互 1. 为什么要自己封装图片星星评分组件——现成方案不够用的三个理由先说一个场景我相信不少做电商、外卖、内容社区的同学都遇到过产品经理拿着一版高保真图过来评分模块用的是品牌定制的星星素材不是 Material 默认的五角星可能要带描边、带渐变甚至满星和空星是两张完全不同的插画风格。这时候你在 Android Compose 项目里第一反应是找轮子但找一圈下来会发现能用的大多是 Compose 早期版本写的或者干脆只支持 Icon 类型的星星想换成任意图片素材就得改库源码。与其去改别人维护不积极的库不如自己封装一个图片星星评分组件需求越定制化自研成本反而越低。第一默认方案的美观度撑不起业务需求。Compose Material 里的Icon搭配Icons.Filled.Star或Icons.Filled.StarBorder做技术 Demo 没问题但真实业务里尤其是面向 C 端的页面星星的视觉风格几乎都是设计师单独出的。默认五角星粗细、颜色、阴影都不好调强行用Icon加tint得到的只是一个单色替换的星星距离图片星星差得远。第二现成第三方评分库的状态和交互往往写死了。很多库把评分组件定义成用户点一下给个整星的简单交互但真实场景比这复杂得多有可能要求支持半星有可能要求长按后拖拽连续评分有可能评分结果要实时回传给后端做记录还有可能某个页面只展示不可交互的均分结果。组件作者的需求和你不一样时你就得在 his 代码上做大量 adapter越改越痛苦。第三也是最重要的Compose 声明式 UI 的架构让自绘组件的成本大幅下降。传统 View 体系下做一个自定义 View要处理onMeasure、onDraw、onTouchEvent还要考虑invalidate的触发时机稍不注意就各种重绘问题。Compose 里一个自定义组件本质就是一个Composable函数加上状态管理和手势处理思路非常直观画满星图层、画空星图层、按评分裁剪宽度、把触摸位置换算成分数。整个实现不到两百行这个投入产出比已经足够支撑自研。所以这篇博文的目标很直接从零实现一个可复用的 Android Compose 图片星星评分组件支持点击评分、拖拽评分、半星和任意小数精度最关键的是星星外观完全由你传入的图片资源决定品牌想要什么风格都接得住。方案不需要引入第三方评分库依赖面极小接进现有项目也比较顺手。2. 动手前先想清楚组件的状态边界与绘制方案我见过不少人写自定义组件上来就写 UI写到一半发现状态该放哪、图片怎么裁、尺寸怎么算全乱了。评分组件虽然小但状态边界和绘制策略必须提前定好否则后面扩展会很别扭。2.1 受控组件还是非受控组件评分状态必须交给业务层受控和非受控是从前端领域借来的概念放在 Compose 里就是指组件内部到底扛不扛分数这个状态。如果你的ImageStarRatingBar内部用remember { mutableStateOf(0f) }自己存评分用户点了之后分数变了但业务层拿不到这个值或者拿到之后还要再设回来就会产生组件内部状态和业务层状态两份数据很容易不同步。比如服务端下发了一个用户已有的评分 4.5组件初始化应该显示 4.5但用户点了一颗星之后内部状态改了页面其他区域的我的评分文案也要跟着变如果状态不在同一个地方逻辑就会绕。我的建议是组件做成完全受控评分值由外部传入用户交互触发回调组件内部不维护任何持久状态。函数签名就是这个思路Composable fun ImageStarRatingBar( rating: Float, // 当前评分完全由外部控制 onRatingChange: (Float) - Unit, // 用户操作后的回调组件不自己存分数 // ...其他参数 )这样做的好处第一是单一数据源业务层能随时知道当前分数第二是方便以后接 ViewModel 和本地持久化评分变化只需要在回调里做一次赋值第三是复用容易同一个组件既能在详情页做可交互评星也能在列表里用同一个rating参数做展示不交互时把回调置空即可。有个细节要注意虽然组件不存状态但拖拽过程中为了手感流畅回调可能被触发很多次。如果每次都把rating写进 ViewModel 再通过collect流式返回中间会有状态流转延迟视觉上可能卡一下。实际项目里我会在 UI 层维护一个临时的mutableStateOf值用于拖拽过程中的平滑变化拖拽结束时再把最终值提交给 ViewModel。这个临时状态依然不属于组件内部逻辑而是页面层面对高频 UI 变化和低频业务提交做的协调。2.2 双图层裁切比逐颗重绘更高效的绘制策略绘制五颗星星最容易想到的做法是遍历评分满星的画满星图、不满的画空星图。这么做有一个问题当评分是 3.7 这种小数时最后那一颗既不是满星也不是空星必须画一张部分填充的星星图。而图片素材只提供了满星和空星两张剩下的只能自己裁。这里我推荐一个经典方案双图层叠加加裁切。底层先平铺starCount默认 5颗空星图顶层再平铺同样数量的满星图然后用Modifier.width(按评分计算出的像素宽度)加clipToBounds()将顶层整体裁掉一部分只露出满足分值的宽度。这样无论评分是整数、半星还是任一小数视觉上都很自然而且没有逐颗判断的复杂度。为什么不用 Canvas 绘制因为图片星星的美术资源通常是一张张 PNG 或 SVG用 Canvas 绘制还得自己处理drawImage的缩放、比例、偏移代码量不小。而 Compose 的Image本身已经处理好了这些工作我只需要控制顶层宽度借系统能力完成裁剪既省事又稳定。性能上也不必担心两层Image都是静态绘制宽度变化只会触发测量和重绘五颗星的组件开销完全可以忽略。2.3 尺寸、间距与裁剪宽度的精确计算这个组件最容易被忽视的就是间距。如果五颗星之间没有间距裁剪宽度直接按评分/总数 * 总宽度算就能得到不错的效果。可一旦加了starSpacing就有一个细节问题当评分为 2.5 时顶层需要显示两颗满星 一颗半星 两颗满星与半星之间的间距也就是说间距也要跟着计进裁剪宽度里否则半颗星星和下一颗星星会靠得太近。如果直接按线性比例裁剪在间距较大时最后半颗星会被多裁掉一部分视觉上星星的半程度不准确。所以我在实现里单独写了一个计算逻辑把完整星星宽度和部分星星宽度分开算fun calculateFilledWidthPx( rating: Float, starCount: Int, starSizePx: Float, starSpacingPx: Float ): Float { val boundedRating rating.coerceIn(0f, starCount.toFloat()) val fullStars floor(boundedRating).toInt() val fraction boundedRating - fullStars val fullStarWidth fullStars * (starSizePx starSpacingPx) val partialWidth if (fraction 0f) { val starPart fraction * starSizePx // 如果不是最后一颗后面还需要跟一个间距让下一颗空星自然分隔 val spacingPart if (fullStars starCount - 1) starSpacingPx else 0f starPart spacingPart } else { 0f } return fullStarWidth partialWidth }这里计算的前提是顶层底层两排星星的Arrangement.spacedBy(starSpacing)和控件总宽完全一致等于starCount * starSize (starCount - 1) * starSpacing。底层空星先铺满整个区域顶层宽度超过控件总宽时会被clipToBounds()裁掉最后的无效间距问题不大。组件暴露的参数用Dp内部计算时通过LocalDensity.current.run { starSize.toPx() }转成像素这样在使用侧可以直观地写starSize 28.dp。3. 核心代码实现一个可用的 ImageStarRatingBar思路理完后代码其实就水到渠成了。我下面给出一个可以直接用的完整版本然后逐个参数和关键点说明。3.1 参数设计与基础 Composable 骨架先看函数签名和骨架Composable fun ImageStarRatingBar( rating: Float, onRatingChange: (Float) - Unit, modifier: Modifier Modifier, starCount: Int 5, starSize: Dp 32.dp, starSpacing: Dp 8.dp, emptyStar: Painter, filledStar: Painter, step: Float 0.5f, isIndicator: Boolean false ) { val density LocalDensity.current val starSizePx with(density) { starSize.toPx() } val starSpacingPx with(density) { starSpacing.toPx() } val totalWidthPx starCount * starSizePx (starCount - 1) * starSpacingPx var touchRating by remember { mutableStateOf(rating) } val displayRating if (isIndicator) rating else touchRating Box( modifier modifier .width(starSize * starCount starSpacing * (starCount - 1)) .height(starSize) .clipToBounds(), contentAlignment Alignment.CenterStart ) { // 底层空星 Row(horizontalArrangement Arrangement.spacedBy(starSpacing)) { repeat(starCount) { index - Image( painter emptyStar, contentDescription null, modifier Modifier.size(starSize) ) } } // 顶层满星宽度按评分裁切 Box( modifier Modifier .fillMaxHeight() .width(calculateFilledWidthDp(displayRating)) .clipToBounds() ) { Row(horizontalArrangement Arrangement.spacedBy(starSpacing)) { repeat(starCount) { index - Image( painter filledStar, contentDescription null, modifier Modifier.size(starSize) ) } } } } }这里我加了一个touchRating的本地可变状态听着是不是和前面说的受控组件不维护状态有点矛盾其实不矛盾这个状态只存在于当前组合的生命周期内本质是拖拽过程中的像素级反馈值而不是业务数据。交互结束后最终值还是要通过onRatingChange交出去。如果完全不用它拖拽时每移动一个像素就把数值回调给外部外部再通过 recomposition 把新值传回来在低端机上容易出现肉眼可感的延迟所以组件内部留一个极短命的中间状态来保证手势跟手。这个方案也是很多成熟自定义组件的通用做法。calculateFilledWidthDp就是把 2.3 里的像素计算封装了一下转回Dp给Modifier.width使用。3.2 满星层的裁剪逻辑与完整代码把裁剪计算补全同时给step参数加上归一化处理private fun Float.normalizeStep(step: Float, max: Int): Float { if (step 0f) return this val scale 1f / step return (this * scale).roundToInt() / scale .coerceIn(0f, max.toFloat()) } Composable private fun calculateFilledWidthDp( rating: Float, starCount: Int, starSizePx: Float, starSpacingPx: Float ): Dp { val widthPx calculateFilledWidthPx( rating rating, starCount starCount, starSizePx starSizePx, starSpacingPx starSpacingPx ) return with(LocalDensity.current) { widthPx.toDp() } }这里normalizeStep解决的问题是用户滑到了 3.7但业务要求只能打整星或半星那就把 3.7 归一到 3.5这样顶层裁剪的宽度和最终上报的分数是一致的不会出现显示 3.7、上报 3.5 的视觉与数据不一致。step 1f就是整星step 0.5f就是半星step 0.1f可以支持更细粒度评分。3.3 用 painterResource / AsyncImage 对接图片资源组件本身不关心图片是怎么来的它接收的是 Compose 的Painter所以图片资源有两种常见接法。第一种是本地 drawable直接用painterResourceImageStarRatingBar( rating 4.5f, onRatingChange { /* 处理评分 */ }, emptyStar painterResource(id R.drawable.star_empty), filledStar painterResource(id R.drawable.star_filled) )第二种是网络图片或需要做过渡动画的图片可以配合 Coil 的AsyncImage。但我一般不直接把AsyncImage塞进组件里而是推荐在调用侧把网络图转化为Painter再传入。一个简单做法是封装一个rememberNetworkPainter(url: String?): Painter?内部用SubcomposeAsyncImage加onSuccess把结果存进remember的Painter状态里图片没加载出来时可以先传给组件一张占位Painter等加载完成再触发重组。这里有个非常容易踩的坑加载网络图时图片的实际尺寸可能不是正方形或者空星和满星素材的视觉中心不一样直接Modifier.size(starSize)会把图片拉伸变形或者出现两把星星错位的观感。解决方式是在资源侧就先约定好必须给正方形素材同时组件内用ContentScale.Crop保证占满指定尺寸在每颗Image上加上contentScale ContentScale.Crop。如果你希望保留图片的透明度或描边效果可以考虑ContentScale.Fit并调整背景但绝大多数场景下 Crop 加正方形素材是最省心的组合。4. 点击与拖拽评分手势处理的完整方案有了静态展示接下来就是把用户的手势变成分数。这部分通常不是最大的代码量却是最容易出交互 bug 的地方。4.1 从触摸位置反算评分值触摸换算的核心就是把offset.x映射到0..starCount区间private fun calculateRatingFromOffset( offsetX: Float, starCount: Int, starSizePx: Float, starSpacingPx: Float ): Float { val totalWidth starCount * starSizePx (starCount - 1) * starSpacingPx val x offsetX.coerceIn(0f, totalWidth) // 当前触碰位置涵盖了第几个星加间距单元 val starUnit starSizePx starSpacingPx val fullPart (x / starUnit).toInt().coerceAtMost(starCount) val remainder x - fullPart * starUnit val fraction if (remainder 0f fullPart starCount) { (remainder / starSizePx).coerceIn(0f, 1f) } else { 0f } return fullPart fraction }简单说就是把星星和间距看成一个个等宽单元先数完整经过了几个单元再算当前坐标落在单元内的比例。这个函数在点击和拖拽两个场景里都能复用。4.2 步长策略支持 0.5 星和任意精度点击评分时用detectTapGestures拿到Offset后直接换算并归一化.pointerInput(starCount, starSizePx, starSpacingPx, step, isIndicator) { detectTapGestures( onTap { offset - if (!isIndicator) { val rawRating calculateRatingFromOffset( offsetX offset.x, starCount starCount, starSizePx starSizePx, starSpacingPx starSpacingPx ) onRatingChange(rawRating.normalizeStep(step, starCount)) } } ) }拖拽评分时用detectDragGestures同时更新本地touchRating以获得跟手效果.pointerInput(starCount, starSizePx, starSpacingPx, step, isIndicator) { detectDragGestures( onDragStart { offset - if (!isIndicator) { val raw calculateRatingFromOffset( offsetX offset.x, starCount starCount, starSizePx starSizePx, starSpacingPx starSpacingPx ) touchRating raw.normalizeStep(step, starCount) } }, onDrag { change, _ - if (!isIndicator) { change.consume() val raw calculateRatingFromOffset( offsetX change.position.x, starCount starCount, starSizePx starSizePx, starSpacingPx starSpacingPx ) touchRating raw.normalizeStep(step, starCount) } }, onDragEnd { if (!isIndicator) { onRatingChange(touchRating) } } ) }change.consume()这段很重要它告诉 Compose 这个事件已经由我们处理避免事件继续冒泡导致父容器也响应。若漏掉这行页面里的其他横向手势组件有可能被误触发。4.3 在可滚动父容器中如何处理触摸冲突评分组件经常出现在可滚动的列表或详情页里比如商品评价列表里每条评价下面都跟着一个可评分的星星条。这时组件会和父级verticalScroll或者LazyColumn的手势抢事件。我的经验是把评分条做成点击为主、拖拽为辅的交互不要在组件上启用复杂度太高的手势组合。单纯的detectTapGestures不会和垂直滚动冲突用户点击星星即可完成评分这符合大多数业务预期。如果需要拖拽连续评分则在onDrag里change.consume()并且处理onDragCancel在取消时把touchRating复位或者直接提交当前值。另一个实用的技巧是把组件交互区域稍微做小一点。评分组件通常视觉高度 24dp 到 32dp如果你给它一个上下延伸的minimumInteractiveComponentSize()可以扩大触达区域而不影响视觉。默认情况下 Compose 对可点击组件要求最少 48dp 的触达区域在紧凑布局中这个默认值会使评分条和上下内容发生误触所以我会在组件最外层添加Modifier .requiredSizeIn(minWidth 1.dp, minHeight 1.dp)将系统的最小触摸约束解除然后自己用手指热区处理。比如可以额外包一层Box让实际手势检测区域比星星可视区域上下各扩展 12dp既不影响布局又提升了易用性。5. 实际接入过程中踩过的坑与规避方式这部分是真实项目落地时我遇到的几个高频问题每一条都折磨过我好一阵子。5.1 图片加载导致的星星错位与闪烁问题场景用AsyncImage直接加载网络星星素材加载过程中显示占位图加载完成后替换成真图。如果占位图和真图的尺寸不一致或者加载的两颗星星的图片重心不同整排星星会在加载完成瞬间跳一下严重的时候出现上下错位。规避方式有三条。第一所有星星素材在设计阶段就固定为同一个尺寸规格比如一律是 96x96 的透明底 PNG组件内用ContentScale.Crop再裁切到目标尺寸。第二如果必须用SubcomposeAsyncImage把loading和error状态的 Painter 也用同一个placeholder并且placeholder的宽高比和真图一致否则裁切结果会闪。第三网络图加载完成后需要触发重组直接在调用侧把Painter通过remember缓存避免每次ImageStarRatingBar重组时都重新加载一次。5.2 配置变更后评分被重置成默认值这个坑很隐蔽。如果页面里评分状态只存在remember { mutableStateOf(0f) }一旦旋转屏幕或者系统触发配置变更Activity 重建评分就回落到 0。用户明明打好了四星转个屏变成未评分非常劝退。解决办法是使用rememberSaveable。Compose 会自动把Float类型的值通过Bundle存下来配置变更后自动恢复var rating by rememberSaveable { mutableStateOf(0f) }如果你的评分值不只是 Float还需要存一个图片 URL 或对象那就得自定义Saver或者干脆只存原始值、图片 URL 在重新加载时从 ViewModel 取。这个组件本身因为是受控组件状态恢复的责任在业务层但只要用rememberSaveable就能解决 90% 的场景。5.3 无障碍朗读分数必须可感知可调节国内项目对无障碍的重视程度参差不齐但评分组件如果不做语义化TalkBack 用户会听到一串毫无意义的图片图片图片。我在组件里加了这样的语义描述.semantics { contentDescription 评分 $displayRating共 $starCount 星 if (!isIndicator) { progressBarRangeInfo ProgressBarRangeInfo( current displayRating, range 0f..starCount.toFloat(), steps ((starCount * (1f / step)) - 1).toInt() ) } }progressBarRangeInfo不仅能让 TalkBack 读出4.5共5星系统还会自动赋予调节进度的滑块模式视力障碍用户可以通过上下滑动调整评分。这个设置成本极低但价值很高建议保留。5.4 LazyColumn 中的性能注意点在LazyColumn列表项中复用这个组件时要注意避免把图片加载逻辑写在重组频繁的地方。比如不要这样写items(items) { item - ImageStarRatingBar( rating item.rating, onRatingChange { ... }, emptyStar painterResource(id R.drawable.star_empty), // 每次重组都会创建 filledStar ... ) }painterResource内部有缓存频繁调用未必会有明显性能问题但更稳妥的做法是预先取得Painter或通过remember包裹val emptyStar painterResource(id R.drawable.star_empty) val filledStar painterResource(id R.drawable.star_filled)列表项的数据更新通常只影响rating这一个参数其他参数保持不变时Compose 的Stable机制能跳过大部分重组工作。如果列表图片特别多也可以用derivedStateOf或把Painter提升到父级统一管理避免每条 item 都持有资源引用。6. 让组件真正服务业务动画、持久化与可测试性评分组件不是画完星星就算完事到了生产环境还要考虑动画反馈、业务数据持久化和测试覆盖。6.1 平滑变化动画用户打分时如果直接一刀切改变顶层宽度视觉上会显得生硬。用animateFloatAsState给显示值加一点流动性效果会好很多val animatedRating by animateFloatAsState( targetValue displayRating, animationSpec tween(durationMillis 300), label rating )然后裁剪宽度基于animatedRating计算。这样点击一整颗星时满星层会从原来的分数平滑长到新分数很像很多 App 里的评分反馈动效。不过注意拖拽过程中不需要让动画参与否则会拖慢跟随速度我通常只在点击评分时加动画拖拽时直接使用即时值。判断方式很简单通过isIndicator加一个animateOnChange参数就行。6.2 与 ViewModel、DataStore 的联动在一个完整的业务页面里评分组件的回调应当最终落到 ViewModelviewModel.submitRating(value)提交之后可能需要走网络请求在服务器返回成功前UI 要继续保持用户看到的评分。这个逻辑用受控组件处理起来很清晰rating直接来自viewModel.uiState.rating用户点击后先乐观更新uiState再调用接口失败则回滚。哪怕组件内部用了touchRating做拖拽缓存最后一次onRatingChange之后的渲染值仍然由外部状态决定所以回滚只需要改 ViewModel 里的值UI 会自动校正。本地持久化可以用DataStore存一个 Float 值登录用户也可以和服务端同步。这块不是本组件的工作但建议把持久化和网络提交放在 ViewModel 里做组件保持纯粹的展示和交互职责。6.3 Compose UI 测试与盲区Compose 项目里可以用runComposeUiTest写组件测试重点覆盖两块触摸换算和步长归一化。把calculateRatingFromOffset和normalizeStep抽成内部私有函数不方便测试我通常把它们定义成internal或者作为组件伴生对象里的纯函数方便单测直接调用。下面是一个典型的 UI 测试示例Test fun clickingThirdStar_setsRatingToOne() { composeTestRule.setContent { var rating by remember { mutableStateOf(0f) } ImageStarRatingBar( rating rating, onRatingChange { rating it }, emptyStar painterResource(R.drawable.star_empty), filledStar painterResource(R.drawable.star_filled), step 1f ) } composeTestRule.onNodeWithTag(star_rating_bar).performClick() // 断言 rating 等于预期值 }测试盲区在于detectDragGestures的模拟拖拽不如点击稳定实际项目中我倾向于对点击评分做完整测试拖拽路径则做少量冒烟用例防止回归。最后再分享一个实践经验图片星星评分组件的重点从来不是画星星而是把业务分数的精度、视觉反馈、可访问性和状态持久化这些边界都收拾干净。我在多个项目里复用这套结构改的只是Painter素材和步长参数交互和状态逻辑基本不动。组件越通用后面业务扩展就越省心。
返回列表