本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!
开发项目的时候,难免会遇到原生控件无法满足,需要自定义的情况,今天通过绘制几个图表来练习一下Jetpack Compose 中的自定义View。
线形图
绘制原理和之前xml中一样,只不过实现的方式变了一些,比之前简单了很多,比如下面通过path来绘制线形图。构建好path之后,直接在Canvas中绘制就OK了。
如果想要对图标进行双指缩放,可以通过Modifier.graphicsLayer().transformable()
来监听手势。通过rememberTransformableState
来监听手指缩放的大小然后将返回值赋值给相应的变量就可以啦
完整代码:
data class Point(val X: Float = 0f, val Y: Float = 0f)
@Composable
fun LineChart() {
//用来记录缩放大小
var scale by remember { mutableStateOf(1f) }
val state = rememberTransformableState {
zoomChange, panChange, rotationChange ->
scale*=zoomChange
}
val point = listOf(
Point(10f, 10f), Point(50f, 100f), Point(100f, 30f),
Point(150f, 200f), Point(200f, 120f), Point(250f, 10f),
Point(300f, 280f), Point(350f, 100f), Point(400f, 10f),
Point(450f, 100f), Point(500f, 200f)
)
val path = Path()
for ((index, item) in point.withIndex()) {
if (index == 0) {
path.moveTo(item.X*scale, item.Y)
} else {
path.lineTo(item.X*scale, item.Y)
}
}
val point1 = listOf(
Point(10f, 210f), Point(50f, 150f), Point(100f, 130f),
Point(150f, 200f), Point(200f, 80f), Point(250f, 240f),
Point(300f, 20f), Point(350f, 150f), Point(400f, 50f),
Point(450f, 240f), Point(500f, 140f)
)
val path1 = Path()
path1.moveTo(point1[0].X*scale, point1[0].Y)
path1.cubicTo(point1[0].X*scale, point1[0].Y, point1[1].X*scale, point1[1].Y, point1[2].X*scale, point1[2].Y)
path1.cubicTo(point1[3].X*scale, point1[3].Y, point1[4].X*scale, point1[4].Y, point1[5].X*scale, point1[5].Y)
path1.cubicTo(point1[6].X*scale, point1[6].Y, point1[7].X*scale, point1[7].Y, point1[8].X*scale, point1[8].Y)
path1.cubicTo(point1[7].X*scale, point1[7].Y, point1[8].X*scale, point1[8].Y, point1[9].X*scale, point1[9].Y)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color.White)
//监听手势缩放
.graphicsLayer(
).transformable(state)
) {
//绘制 X轴 Y轴
drawLine(
start = Offset(10f, 300f),
end = Offset(10f, 0f),
color = Color.Black,
strokeWidth = 2f
)
drawLine(
start = Offset(10f, 300f),
end = Offset(510f, 300f),
color = Color.Black,
strokeWidth = 2f
)
//绘制path
drawPath(
path = path,
color = Color.Blue,
style = Stroke(width = 2f)
)
drawPath(
path = path1,
color = Color.Green,
style = Stroke(width = 2f)
)
}
}
柱状图
下面来绘制柱状图,绘制很简单,直接根据坐标绘制矩形就可以了。Jetpack Compose中的绘制矩形的API跟之前XML中的API不大一样,需要提供绘制的左上角和矩形的大小就可以绘制了,看一下构造函数就知道了。
然后给柱子加上点击事件,Jetpack Compose中监听点击的屏幕位置坐标使用Modifier中的pointerInput方法,然后判断点击的坐标是否在矩形的范围之内即可,下面代码中只判断了X轴 的坐标,也可以在加上Y轴的判断。
最后再给柱形图加上动画,动画使用animateFloatAsState
方法,值设置为0到1代表当前绘制高度的百分比,然后绘制的时候给高度添加该百分比的值就OK了。
完整代码:
private fun identifyClickItem(points: List<Point>, x: Float, y: Float): Int {
for ((index, point) in points.withIndex()) {
if (x > point.X+20 && x < point.X + 20+40) {
return index
}
}
return -1
}
@Composable
fun BarChart() {
val point = listOf(
Point(10f, 10f), Point(90f, 100f), Point(170f, 30f),
Point(250f, 200f), Point(330f, 120f), Point(410f, 10f),
Point(490f, 280f), Point(570f, 100f), Point(650f, 10f),
Point(730f, 100f), Point(810f, 200f)
)
var start by remember { mutableStateOf(false) }
val heightPre by animateFloatAsState(
targetValue = if (start) 1f else 0f,
animationSpec = FloatTweenSpec(duration = 1000)
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(Color.White)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
val i = identifyClickItem(point, it.x, it.y)
Log.d("pointerInput", "onTap: ${it.x} ${it.y} item:$i")
Toast
.makeText(this@FourActivity, "onTap: $i", Toast.LENGTH_SHORT)
.show()
}
)
}
) {
//绘制 X轴 Y轴
drawLine(
start = Offset(10f, 600f),
end = Offset(10f, 0f),
color = Color.Black,
strokeWidth = 2f
)
drawLine(
start = Offset(10f, 600f),
end = Offset(850f, 600f),
color = Color.Black,
strokeWidth = 2f
)
start = true
for (p in point) {
drawRect(
color = Color.Blue,
topLeft = Offset(p.X + 20, 600 - (600 - p.Y) * heightPre),
size = Size(40f, (600 - p.Y) * heightPre)
)
}
}
}
饼图
最后绘制一个饼图,饼图的实现方式可以通过绘制drawPath
和drawArc
两种方式实现,drawArc的方式简单一点。
给饼图中的每一块添加点击事件,点击事件也是在Modifier的pointerInput方法中监听点击的坐标。Math.atan2()
返回从原点(0,0) 到 (x,y)的线与x轴正方向的弧度值,然后通Math.toDegrees()
方法把弧度转化为角度,最后通过角度获取点击的区域。
完整代码:
private fun getPositionFromAngle(angles:List<Float>,touchAngle:Double):Int{
var totalAngle = 0f
for ((i, angle) in angles.withIndex()) {
totalAngle +=angle
if(touchAngle<=totalAngle){
return i
}
}
return -1
}
@Composable
fun PieChart() {
val point = listOf(10f, 40f, 20f, 80f, 100f, 60f)
val color = listOf(Color.Blue, Color.Yellow, Color.Green, Color.Gray, Color.Red, Color.Cyan)
val sum = point.sum()
var startAngle = 0f
val radius = 200f
val rect = Rect(Offset(-radius, -radius), Size(2 * radius, 2 * radius))
val path = Path()
val angles = mutableListOf<Float>()
val regions = mutableListOf<Region>()
var start by remember { mutableStateOf(false) }
val sweepPre by animateFloatAsState(
targetValue = if (start) 1f else 0f,
animationSpec = FloatTweenSpec(duration = 1000)
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color.White)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
Log.d(
"pointerInput",
"onTap: ${it.x - radius.toInt()} ${it.y - radius.toInt()} ${regions}"
)
var x = it.x - radius
var y = it.y - radius
var touchAngle = Math.toDegrees(Math.atan2(y.toDouble(),x.toDouble()))
//坐标1,2象限返回-180~0 3,4象限返回0~180
if(x<0&&y<0 || x>0&&y<0){//1,2象限
touchAngle += 360;
}
val position = getPositionFromAngle(touchAngle = touchAngle,angles = angles)
Toast
.makeText(
this@FourActivity,
"onTap: $position",
Toast.LENGTH_SHORT
)
.show()
}
)
}
) {
translate(radius, radius) {
start = true
for ((i, p) in point.withIndex()) {
var sweepAngle = p / sum * 360f
println("sweepAngle: $sweepAngle p:$p sum:$sum")
path.moveTo(0f, 0f)
path.arcTo(rect = rect, startAngle, sweepAngle*sweepPre, false)
angles.add(sweepAngle)
drawPath(path = path, color = color[i])
path.reset()
// drawArc(color = color[i],
// startAngle = startAngle,
// sweepAngle = sweepAngle,
// useCenter = true,
// topLeft = Offset(-radius,-radius),
// size = Size(2*radius,2*radius)
// )
startAngle += sweepAngle
}
}
}
}
Jetpack Compose 刚出来有一些功能还不完善,可以在drawIntoCanvas
的作用域中使用使用原来的canvas,按照原来的方式来绘制。drawIntoCanvas作用域内的对象是一个canvas,通过it.nativeCanvas
方法可以返回一个原生Android中的canvas对象。我们就可以通过它来按照原来的方式绘制了。
比如上面的饼图的点击事件,原来我们可以通过Path和Region这两个类结合,计算出每一块的绘制区域。但是在使用的时候发现Jetpack Compose的UI包中没有对应的Region类,只有对应的Path类,想要使用上面的功能就只能使用原来的Path类和Region来计算了。使用方式如下:
@Composable
fun PieChart1(){
val point = listOf(10f, 40f, 20f, 80f, 100f, 60f)
val colors = listOf(Color.Blue, Color.Yellow, Color.Green, Color.Gray, Color.Red, Color.Cyan)
val sum = point.sum()
var startAngle = 0f
val radius = 200f
val path = android.graphics.Path()
val rect = android.graphics.RectF(-radius,-radius,radius,radius)
val regions = mutableListOf<Region>()
val paint = Paint()
paint.isAntiAlias = true
paint.style = Paint.Style.FILL
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color.White)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
Log.d(
"pointerInput",
"onTap: ${it.x - radius.toInt()} ${it.y - radius.toInt()} ${regions.toString()}"
)
val x = it.x - radius
val y = it.y - radius
var position = -1
for ((i, region) in regions.withIndex()) {
if(region.contains(x.toInt(),y.toInt())){
position = i
}
}
Toast
.makeText(
this@FourActivity,
"onTap: $position",
Toast.LENGTH_SHORT
)
.show()
}
)
}
) {
translate(radius, radius) {
drawIntoCanvas {
for ((i, p) in point.withIndex()) {
var sweepAngle = p / sum * 360f
println("sweepAngle: $sweepAngle p:$p sum:$sum")
path.moveTo(0f, 0f)
path.arcTo(rect,startAngle,sweepAngle)
//计算绘制区域并保存
val r = RectF()
path.computeBounds(r,true)
val region = Region()
region.setPath(path, Region(r.left.toInt(),r.top.toInt(),r.right.toInt(),r.bottom.toInt()))
regions.add(region)
paint.color = colors[i].toArgb()
it.nativeCanvas.drawPath(path,paint)
path.reset()
startAngle += sweepAngle
}
}
}
}
}
运行效果跟跟前面绘制的饼图效果一样。
总结:Jetpack Compose中自定义View的API比原来的方式简洁了不少,而且当当前API无法满足需求的时候,也可以很方便的使用原来的API进行绘制,体验很不错。