仿BOSS直聘App中简历被查看量的功能——Jetpack Compose实现

1,555 阅读6分钟

背景

马上又要到了一年一度的传统求职旺季 “金三银四”,然而,今年的就业形势依旧不容乐观。海量应届毕业生需要就业,而市场环境却持续低迷,岗位需求不断收缩。很多企业为实现降本增效,纷纷采取裁员措施,这让IT从业者们面临着前所未有的挑战。

不只是小白从业者,经验丰富的资深开发者也不容乐观,面临着35岁危机,这像是一把悬挂在我们头上的达摩克利斯之剑,让人焦虑。毕竟所有人都得面得一个冰冷的事实,就是人都会老。

博主这段时间也在用BOSS直聘APP看职位,发现好的工作是越来越少,打招呼的很多都是外包,难道外包是大龄和学历不太好的IT从业者最后一根救命稻草了吗?

在使用BOSS直聘APP的过程中发现,在消息>互动>看过我页签中,有个简历被查看量的功能挺有意思的,能直观的看出来自己当前的竞争水平。今天,我们就来实现一个类似BOSS直聘中 "简历被查看量" 的双折线图。

效果图

boss4.gif

说明

此图表着重展示两个关键维度的数据:

  • “我的查看量”,这一数据清晰记录着自己简历在平台上被招聘者查看的具体次数;
  • “竞争者平均查看量” ,它反映出同类型求职者简历被查看的平均水平,是了解自身在竞争群体中所处位置的重要参照。

从这样一张双折线图中,我们能非常直观地看出自己和竞争者之间的差距。方便我们在求职过程中精准定位自身优劣势,进而针对性地调整求职策略。比如,若发现自己的查看量持续低于竞争者平均查看量,那就需要思考简历是否存在优化空间。

效果

  • 支持点击查看不同日期的数据,点击时有选中效果。

实现步骤

我们使用 Jetpack Compose 来完成这个 UI的实现,首先通过 LazyRow来实现横向的7个自然日的数据显示。LazyRow里面放的Item组件,上面是一个自定义绘图的Canvas组件来绘制圆圈和折线,下面的日期用Text组件实现。具体实现代码如下:


@Composable
fun DualLineChartWithLazyRow(
    data1: List<Pair<String, Float>>, // 我的查看量
    data2: List<Pair<String, Float>>, // 竞争者平均
    onItemClick: (Int) -> Unit = {},
) {
    var selectIndex by remember { mutableIntStateOf(data2.lastIndex) }

    val screenWidth = LocalConfiguration.current.screenWidthDp.dp - 48.dp
    val itemWidth = screenWidth / data1.size
    LazyRow(
        modifier = Modifier
            .height(210.dp)
            .fillMaxWidth()
    ) {
        itemsIndexed(data1) { index, (label, value1) ->
            val value2 = data2.getOrNull(index)?.second ?: 0f
            Box(
                modifier = Modifier
                    .width(itemWidth)
                    .clickableNoRipple() {
                        selectIndex = index
                        onItemClick.invoke(index)
                    }
                    .fillMaxHeight(),
            ) {
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Bottom
                ) {
                    Canvas(
                        modifier = Modifier
                            .fillMaxWidth()
                            .selectBg(selectIndex == index)
                            .height(180.dp)
                    ) {
                        val maxY = max(data1.maxOf { it.second }, data2.maxOf { it.second }) * 1.1f
                        val normalizedY1 = size.height * (1 - value1 / maxY)
                        val normalizedY2 = size.height * (1 - value2 / maxY)
                        val radius = 5.dp.toPx() // 圆圈半径
                        val radiusBig = 7.dp.toPx() // 大圆圈半径

                        if (index > 0) {
                            val prevX = -itemWidth.toPx() + size.width / 2
                            val prevY1 = size.height * (1 - data1[index - 1].second / maxY)
                            val prevY2 = size.height * (1 - data2[index - 1].second / maxY)

                            val start1 = adjustForCircle(
                                Offset(prevX, prevY1),
                                Offset(size.width / 2, normalizedY1),
                                radius
                            )
                            val end1 = adjustForCircle(
                                Offset(size.width / 2, normalizedY1),
                                Offset(prevX, prevY1),
                                radius
                            )

                            val start2 = adjustForCircle(
                                Offset(prevX, prevY2),
                                Offset(size.width / 2, normalizedY2),
                                radius
                            )
                            val end2 = adjustForCircle(
                                Offset(size.width / 2, normalizedY2),
                                Offset(prevX, prevY2),
                                radius
                            )

                            drawLine(
                                color = Color(0xFFFF4D4D),
                                start = start1,
                                end = end1,
                                strokeWidth = 3.dp.toPx()
                            )

                            drawLine(
                                color = Color(0xFFF07D4E), // 橙色
                                start = start2,
                                end = end2,
                                strokeWidth = 3.dp.toPx()
                            )
                        }
                        // 使用示例
                        drawCircle(
                            Offset(size.width / 2, normalizedY1),
                            Color(0xFFFF4D4D),
                            selectIndex == index,
                            radius,
                            radiusBig
                        )
                        drawCircle(
                            Offset(size.width / 2, normalizedY2),
                            Color(0xFFF07D4E),
                            selectIndex == index,
                            radius,
                            radiusBig
                        )
                        drawLine(
                            color = Color(0xFFECEEF0),
                            start = Offset(size.width, 0f),
                            end = Offset(size.width, size.height),
                            strokeWidth = 1.dp.toPx()
                        )
                    }
                    Box(
                        modifier = Modifier.height(30.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = label,
                            fontSize = 12.sp,
                            textAlign = TextAlign.Center,
                            color = if (selectIndex == index) Color(0xFFFF4D4D) else Color.Gray
                        )
                    }
                }
            }
        }
    }
}

private fun Modifier.selectBg(isSelected: Boolean): Modifier {
    return if (isSelected) {
        this.background(
            Brush.verticalGradient(
                listOf(
                    Color(0xFFFFFAFA),
                    Color(0xFFFFEBEB),
                    Color(0xFFFFEBEB)
                )
            )
        )
    } else {
        this
    }
}

/**
 * 绘制选中的或默认状态下的圆圈数据点
 */
fun DrawScope.drawCircle(
    center: Offset,
    circleColor: Color,
    isSelected: Boolean,
    radius: Float,
    radiusBig: Float
) {
    // 绘制基础圆圈
    drawCircle(
        color = circleColor,
        center = center,
        radius = radius,
        style = if (isSelected) Fill else Stroke(width = 2.dp.toPx())
    )

    // 如果选中,绘制更大的圆圈突出显示
    if (isSelected) {
        drawCircle(
            color = circleColor,
            center = center,
            radius = radiusBig,
            style = Fill
        )
    }
}

/**
 * **修正版:计算调整后的折线端点,确保线不会穿过圆圈**
 */
fun adjustForCircle(start: Offset, end: Offset, radius: Float): Offset {
    val dx = end.x - start.x
    val dy = end.y - start.y
    val distance = sqrt(dx * dx + dy * dy)

    val adjustedRadius = radius

    // 避免过度调整,确保 distance 足够
    if (distance < adjustedRadius) return start

    // 计算单位向量
    val ratio = (distance - adjustedRadius) / distance
    return Offset(
        start.x + dx * ratio,
        start.y + dy * ratio
    )
}

@Composable
fun Modifier.clickableNoRipple(
    onClick: () -> Unit
): Modifier {
    return this.clickable(
        indication = null,  // 取消涟漪效
        interactionSource = remember { MutableInteractionSource() }
    ) {
        onClick.invoke()
    }

}

注意做点击选中效果的时候,clickable 方法默认是会给这个可点击的控件加上了一个系统自带的点击和选中效果,Material3的涟漪效果,像这样。

Screenshot_1739451033.png

这样的默认效果很丑,不符合要求,这时我们可以选择去掉系统自带的选中效果,把使用indication设置为null, 用下面的方法替换掉系统的clickable方法就行:

@Composable
fun Modifier.clickableNoRipple(
    onClick: () -> Unit
): Modifier {
    return this.clickable(
        indication = null,  // 取消涟漪效
        interactionSource = remember { MutableInteractionSource() }
    ) {
        onClick.invoke()
    }

}

indication 参数用于指定点击时的视觉反馈效果(例如涟漪动画),这里将 indication 设置为 null,意味着点击时不显示任何视觉反馈,适用于希望保持界面干净或自定义点击效果的场景。

完成了双折线图,我们开始绘制它的上面和下面的样式,然后调用这个组件,完整代码如下:

class ComposeBossActivity : BaseActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Scaffold() { padding ->
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .background(Color(0xFFF2F2F2))
                        .padding(padding)
                ) {
                    BossSeeScreen()
                }
            }
        }

    }

}


@Preview(showBackground = true)
@Composable
fun BossSeeScreen() {
    val data1 = listOf(
        "2.6" to 15f,
        "2.7" to 16f,
        "2.8" to 20f,
        "2.9" to 20f,
        "2.10" to 30f,
        "昨天" to 15f,
        "今天" to 16f
    )

    val data2 = listOf(
        "2.6" to 6f,
        "2.7" to 6f,
        "2.8" to 6f,
        "2.9" to 6f,
        "2.10" to 6f,
        "昨天" to 6f,
        "今天" to 6f
    )
    var myCount by remember { mutableFloatStateOf(data1.last().second) }
    var avgCount by remember { mutableFloatStateOf(data2.last().second) }
    Card(
        modifier = Modifier
            .padding(12.dp)
            .fillMaxWidth(),
        shape = RoundedCornerShape(10.dp),
        colors = CardDefaults.cardColors(
            containerColor = Color.White
        )
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text(text = "简历被查看量")
                Row(
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    CircleView(Color(0xFFFF4D4D))
                    Text(text = "我的:${myCount.toInt()}")
                    Spacer(modifier = Modifier.width(20.dp))
                    CircleView(Color(0xFFF07D4E))
                    Text(text = "竞争者平均:${avgCount.toInt()}")
                }
            }
            Spacer(modifier = Modifier.height(12.dp))
            DualLineChartWithLazyRow(data1, data2) { index ->
                myCount = data1[index].second
                avgCount = data2[index].second
            }
            Spacer(modifier = Modifier.height(12.dp))
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(40.dp)
                    .background(
                        Color(0xFFEAF8F8),
                        shape = RoundedCornerShape(20.dp)
                    )
                    .padding(horizontal = 20.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = "想被更多Boss看到?")
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(text = "提升曝光", color = Color(0xFF149897))
                    Icon(
                        imageVector = Icons.Default.ArrowForward,
                        contentDescription = "箭头",
                        tint = Color(0xFF149897),
                        modifier = Modifier.size(16.dp)
                    )
                }
            }
        }
    }
}
@Composable
fun CircleView(circleColor: Color) {
    Canvas(
        modifier = Modifier
            .padding(end = 8.dp)
            .size(8.dp)
    ) {
        drawCircle(
            color = circleColor,
            style = Stroke(width = 3.dp.toPx())
        ) // 红色圆圈,表示“我的”
    }
}

至此,这个仿BOSS直聘App中简历被查看量的双折线图就完成了,其实非常的简单,当做Jetpack Compose的小练习吧。

写在结尾

年年都说大环境不好,真的不好时,普通人其实真的也无能为力。有个智者说人生三事,老天爷的事顺着点,自己的事不要依赖别人,别人的事别瞎掺和。
大环境变差,作为一个普通的开发者也只有不断提升自身技能,拓展技术广度和深度,才能应对行业的变化和挑战。毕竟这些都是自己的事,怨不得别人或者外部环境。只好,大家一起加油了!

最后,希望各位同学能在即将到来的金三银四中,找到心仪的岗位,实现职业上的突破!💪🌟