用
Compose
代码直接写出全网最火的可视化动态趋势视频,帅到爆
用Compose
实现应该是全网唯一了互联网智慧大数据分析,图表,和可视化趋势是一种趋势,分析的利器
可视化图表系列如下:
(一)Compose曲线图表库WXChart,你只需要提供数据配置就行了
(二)Compose折线图,贝赛尔曲线图,柱状图,圆饼图,圆环图。带动画和点击效果
(三)全网最火视频,Compose代码写出来,动态可视化趋势视频,帅到爆
(四)全网最火可视化趋势视频实现深度解析,同时新增条形图表
(五)庆元旦,出排名,手撸全网火爆的排名视频,排名动态可视化
(六)Android六边形战士能力图绘制,Compose实现
(七)Android之中美PK,赛事PK对比图Compose实现
(八)Android之等级金字塔之Compose智能实现
上面GIF效果图:是Android Studio模拟器上App中
Compose动画录屏成视频转化后的GIF效果,本身是带音乐背景的。
Android Studio模拟器及项目截图如下:
一、前言
互联网时代发展到今天,数据可视化已经成了当下非常流行的一种形式,我们会经常看到各国GDP随着年份增长的趋势,应用到我们自己业务中,可能也会存在,比如:
- 各大门店销售单量近3个月走势对比
- 各大医院近3个月每日就诊人数走势对比
- 各大城市地铁流量近60个客流量对比
- 各大高校近30年来每年排名走势对比
二、可视化数据发展的几个阶段
原始阶段
:只能看数据库,或者只能手动录入Excel表格,或者到处表格看,缺点数据不实时图形阶段
:将数据做成折线图、曲线图、条形图、柱状图,圆饼图,圆环图等,缺点图表没有动态进阶阶段
:将数据用工具或网站做成可视化动态趋势视频,缺点数据无法动态,每次得拿数据来制作高级阶段
:用动画配上音乐用代码实现,数据可以直接更新,图表也有走势动态化趋势,貌似JS实现最多了,Android上面,或者用Compose实现的到目前应该还没有的,缺点:需要改动画走势动画,得改源码,得发布新版资深阶段
:上面第4阶段必须实现,同时需要修改动画,可以动态发布。app或者程序无需发布新版
本文主要介绍用Compose代码怎么实现到上面第4阶段
第5阶段
我是可以轻松实现的,可以参考我前面介绍的文章:
大型项目架构:全动态插件化+模块化+Kotlin+协程+Flow+Retrofit+JetPack+MVVM+极限瘦身+极限启动优化+架构示例+全网唯一
本文 Demo工程
我就不做成全动态化项目工程了
三、Compose代码实现上面效果
- 上面整个背景是一张图片,已经做成了可配置网络图片了,其他全是用
Canvas
绘制出来的 - 动态数据model类可配置入下:
val chatHBarModel = DynamicModel(arrayKey, mapList, mapImage).apply {
barWidth = toDp(25f) //条形宽度 isAutoBarWidth为false 才可用
yCount = 10 //y轴横线刻度数 ,包含 0,比如0到5 设置为 6
xCount = 4 //X轴横线刻度数 ,包含 0,比如0到5 设置为 6
keyTextOffsetX = toDp(90f) //年份相对右下角X便宜
keyTextOffextY = toDp(90f) //年份相对右下角Y便宜
xLableStep = 1 //X刻度上面文案显示步长
isAutoBarWidth = true //是否自动计算条形宽度
offsetx = toDp(108f) //UI上原点左下角 x偏移
offsetxLable = toDp(30f)//原点 y轴上面刻度文字x偏移 相对控件最左边偏移
offsety = toDp(3f) //UI上原点左下角 y 偏移
offsetyLable = toDp(8f)//原点 y上面刻度文字文字 y偏移 相对控件左下角点,调整Y值文字在竖直中间位置 与横线对齐
durationMillis = 3000 //每个年份间隔执行动画
isTop = true//X轴是否位于上面
offextBarTopY = toDp(18f) //条形距离顶部偏移 ,这里不是视图,是相对Y轴线最上端
offextBarBottomY = toDp(5f) //条形距离底部偏移 ,这里不是视图,是相对Y轴线最下端
formatString = "%.2f亿" //数字格式化设置
multiplier = 1 / 100000000f //数据显示格式所用的乘数
musicUrl = "asset:///vv.mp3" //背景音乐,可配置网络链接
titleColor = Color.White //每个条形名称颜色
dynamicChartName = "1960~2023各国GPD走势Top10" //动态表名称
colotLable = Color.White//X轴上面刻度变化文章颜色
maxValueOffsetRight = toDp(150f)
bgUrl = "https://q5.itc.cn/q_70/images01/20240723/42bdcb602f35471eadddb09908b683f1.jpeg"//整个动画背景图
}
3. 据源Json如下:
4. 将数据源转化为我们想要的数据格式:
val gson = Gson()
val json = getFromAssets("txt/txt.txt")
val dto = gson.fromJson<List<MutableMap<String, String>>>(json, object : TypeToken<List<MutableMap<String, String>>>() {}.type)
val arrayKey = Array(dto[0].size - 4) { DynamicKey("", 0f, 0f) }
val mapImage = mutableMapOf<String, DynamicImage>()
val defaultBitmap = createImageBitmap()
mapImage["美国"] = DynamicImage("https://gss0.baidu.com/-4o3dSag_xI4khGko9WTAnF6hhy/zhidao/pic/item/8435e5dde71190ef3ab597f0c01b9d16fdfa6056.jpg", defaultBitmap)
mapImage["日本"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/6a63f6246b600c338744433e9214460fd9f9d62a0bb6-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?x-bce-process=image/format,f_auto", defaultBitmap)
mapImage["德国"] = DynamicImage("https://pic.xcar.com.cn/img/news_photo/2010/06/21/gKWaAxcBN23773.jpg", defaultBitmap)
mapImage["英国"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/cb8065380cd7912397dd8f558e6c4e82b2b7d0a22ac0-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?x-bce-process=image/format,f_auto", defaultBitmap)
mapImage["法国"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/63d9f2d3572c11dfa9ec31ad377475d0f703908f64bb-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?x-bce-process=image/format,f_auto", defaultBitmap)
mapImage["中国"] = DynamicImage("https://bkimg.cdn.bcebos.com/pic/d0c8a786c9177f3e67097eaf9c852cc79f3df8dcf874?x-bce-process=image/format,f_auto/resize,m_lfit,limit_1,h_1080", defaultBitmap)
mapImage["意大利"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/562c11dfa9ec8a1363273ac34e4b868fa0ec08fa6af5-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?x-bce-process=image/format,f_auto", defaultBitmap)
mapImage["加拿大"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/a8773912b31bb05163d4e5cf3a7adab44bede061-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?", defaultBitmap)
mapImage["巴西"] = DynamicImage("https://img0.baidu.com/it/u=1359402126,1864975690&fm=253&fmt=auto&app=138&f=JPEG?w=384&h=269", defaultBitmap)
mapImage["印度"] = DynamicImage("https://bkimg.cdn.bcebos.com/smart/f636afc379310a55b3194d878c1d54a98226cffcba32-bkimg-process,v_1,rw_1,rh_1,maxl_216,pad_1,color_ffffff?x-bce-process=image/format,f_auto", defaultBitmap)
val mapList by lazy { mutableMapOf<String, MutableList<DynamicBarBean>>() }
var keyNo = 1960
while (keyNo <= 2023) {
doListByKey(dto, keyNo.toString(), mapList, arrayKey)
keyNo++
}
val chatHBarModel = DynamicModel(arrayKey, mapList, mapImage)
5. 动画模型承载类DynamicModel里面数据配置如下:
class DynamicModel(
val arrayKey: Array<DynamicKey>, //存放当前年份特定值,当前年份Key,
val mapList: MutableMap<String, MutableList<DynamicBarBean>>,//数据集,通过当前年份key查找
val mapImage: MutableMap<String, DynamicImage> //每个条形图的动态图标,可配置网络图片
) : ChartBarBaseModel() {
var dynamicChartName = "" //动态表名称
@Stable
var titleColor: Color? = null//每个条形名称颜色
var bgUrl: String = ""//整个动画背景图
var keyTextOffsetX = 0f//年份相对右下角X便宜
var keyTextOffextY = 0f//年份相对右下角Y便宜
var maxValueOffsetRight = 0f//该值为条形右边文字说明所占有的宽度,因为条形最大长度 = X轴线长度-条形右边文字说明所占有的宽度
var musicUrl = ""//背景音乐,可配置网络链接
var formatString: String = ""//数字格式化设置
var multiplier: Float = 1f//数据显示格式所用的乘数
var isPlayComplete = false
var isAutoBarWidth = false//是否自动计算条形宽度
fun maxValue(keyPos: Int) = arrayKey[keyPos].maxValue
fun getTextValueFormat(value: Float): String {
return formatString?.format(value * multiplier) ?: value.toString()
}
}
- 每个年份下面存着的特殊数据:
DynamicKey
类如下:
data class DynamicKey(
val currentKey: String, //当前数据的年份 或者日期,或者其他作为K的值
val maxValue: Float, //当前K值下最大的值
val xMaxValue: Float, //当前x坐标轴上面最大的刻度数值,比当前最大值要小
)
7. 每个条形图图表类DynamicImage
里面配置
data class DynamicImage(
val imgUrl: String, //每个条形图可配置的图标地址
var bitmap: ImageBitmap //需要绘制的bitmap
)
8. 每个条形图数据DynamicBarBean
class DynamicBarBean(
var value: Float, //条形当前值
@Stable val color: Color, //条形颜色
val title: String //条形名称
) {
var diffValue: Float = 0f // 条形下一个值与当前值的差值
var currPos: Int = 0 // 当前排名位置
var nextPos: Int = 0 // 下一年份排名位置
var diffPos: Int = 0 // nextPos - currPos
}
9. 整个视图的Composable,就是最外层,其他所有已经封装好了,数据配置好以后,只需要调用一句drawDynamicBarChart
就可以了
@Composable
fun dynamicBarChart(viewModel: DynamicViewModel ) {
val textMeasurer = rememberTextMeasurer()
val context = LocalContext.current
val chatModel by viewModel.dynamicModel.observeAsState()
var bitmapBg by remember { mutableStateOf(createImageBitmap()) }
var width by remember { mutableStateOf(0) }
var height by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
chatModel?.bgUrl?.takeIf {
it.isNotEmpty()
}.let {
Glide.with(context).asBitmap().load(it).into(object : SimpleTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
bitmapBg = resource.asImageBitmap()
}
})
}
}
chatModel?.let {
Column(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.onSizeChanged {
width = it.width
height = it.height
}
.drawBehind {
drawImage(
bitmapBg, srcOffset = IntOffset.Zero, srcSize = IntSize(bitmapBg.width, bitmapBg.height), //绘制的图片大小
dstOffset = IntOffset.Zero, dstSize = IntSize(
width, height
)
)
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center
) {
Text(text = it.dynamicChartName, fontSize = 22.sp, color = Color.White, style = TextStyle.Default)
}
val modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
drawDynamicBarChart(//
modifier, textMeasurer, TextStyle(
fontSize = 60.sp, fontWeight = FontWeight.Bold, color = Color.Red
), it
)
}
}
}
10. 动态图真正绘制方法:
@Composable
fun drawDynamicBarChart(modifier: Modifier, textMeasurer: TextMeasurer, style: TextStyle, it: DynamicModel) {
var mSize by remember { mutableStateOf(Size(0f, 0f)) }
var start by remember { mutableStateOf(false) }
val context = LocalContext.current
val player = remember { ExoPlayer.Builder(context).build() }
var drawPosition by remember { mutableStateOf(0) }
val drawKeyNo = remember(drawPosition) { it.arrayKey[drawPosition].currentKey }
val width = mSize.width
val height = mSize.height
val barWidth = if (it.isAutoBarWidth) {
(height - 2 * it.offsety - it.offextBarTopY - it.offextBarBottomY - it.yCount * 2f) / it.yCount
} else it.barWidth
val heightDiv = (height - 2 * it.offsety - it.offextBarTopY - it.offextBarBottomY - barWidth) / (it.yCount - 1)
val maxUIBarStartY = height - it.offsety - it.offextBarBottomY - barWidth
val minUIBarStartY = it.offsety + it.offextBarTopY
val availableWidth = width - 1.5f * it.offsetx - it.maxValueOffsetRight
val dList = remember(drawKeyNo) { it.mapList[drawKeyNo]!! }
val floatTweenSpec = remember { FloatTweenSpec(it.durationMillis, easing = LinearEasing) }
var currHeightAnimateValue by remember { mutableStateOf(0) }
val animatedHeightDiff by animateFloatAsState(targetValue = if (start && currHeightAnimateValue == 0) heightDiv else 0f, animationSpec = floatTweenSpec)
var currWidthAnimateValue by remember { mutableStateOf(0) }
val animateWidthDiff by animateFloatAsState(targetValue = if (start && currWidthAnimateValue == 0) 100f else 0f, animationSpec = floatTweenSpec)
val colorXDivText by animateColorAsState(targetValue = if (start && currWidthAnimateValue == 0) it.colotLable else Color.Transparent, animationSpec = tween(durationMillis = it.durationMillis, easing = LinearEasing))
val animateHValue = if (currHeightAnimateValue == 0) {
animatedHeightDiff
} else {
(heightDiv - animatedHeightDiff)
}
val animateWValue = if (currHeightAnimateValue == 0) {
animateWidthDiff
} else {
(100f - animateWidthDiff)
}
val maxX = it.maxValue(drawPosition) + (dList[dList.size - 1].diffValue / 100f) * animateWValue
val xValue = it.arrayKey[drawPosition].xMaxValue / (it.xCount)
val xAbs = availableWidth / maxX
val xMaxWidth = availableWidth * it.arrayKey[drawPosition].xMaxValue / maxX
val xDiv = xMaxWidth / (it.xCount)
val mapImage = remember { it.mapImage }
LaunchedEffect(Unit) {
player.run {
setMediaItem(MediaItem.fromUri(Uri.parse(it.musicUrl)))
}
it.mapImage.forEach { k, v ->
Glide.with(context).asBitmap().load(v.imgUrl).into(object : SimpleTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
it.mapImage[k]!!.bitmap = resource.asImageBitmap()
}
})
}
delay(it.animateDelay)
start = true
player.run {
prepare()
play()
}
while (drawPosition < it.arrayKey.size - 1) {
delay(it.durationMillis.toLong())
drawPosition += 1
if (currHeightAnimateValue == 0) currHeightAnimateValue = 1
else currHeightAnimateValue = 0
if (currWidthAnimateValue == 0) currWidthAnimateValue = 1
else currWidthAnimateValue = 0
}
if (drawPosition == it.arrayKey.size - 1) {
player.stop()
player.release()
it.isPlayComplete = true
}
}
Canvas(modifier = modifier) {
mSize = size
if (it.isShowYLine)
//绘制 Y轴
drawLine(
start = Offset(it.offsetx, height - it.offsety), end = Offset(it.offsetx, it.offsety + if (it.isTop) it.offextBarTopY else 0f), color = it.colorYLine, strokeWidth = 2f
)
//绘制 X轴
drawLine(
start = Offset(it.offsetx, if (it.isTop) it.offsety + it.offextBarTopY else (height - it.offsety)), end = Offset(width - it.offsetx / 2 - it.maxValueOffsetRight, if (it.isTop) it.offsety + it.offextBarTopY else (height - it.offsety)), color = it.colorXLine, strokeWidth = 2f
)
for (i in 0..it.xCount step it.xLableStep) {
//绘制x轴上几条竖线
drawLine(
start = Offset(it.offsetx + i * xDiv, height - it.offsety), end = Offset(it.offsetx + i * xDiv, it.offsety + if (it.isTop) it.offextBarTopY else 0f), color = it.colorLine, strokeWidth = 1f
)
//绘制x轴上几条竖线对应的X值
if (start) drawText(
textMeasurer = textMeasurer, text = "${it.getTextValueFormat(i * xValue)}", topLeft = Offset(it.offsetx + i * xDiv - it.offsetxLable, if (it.isTop) it.offsety else (height - it.offsety)), style = TextStyle(color = colorXDivText)
)
}
for (m in dList.lastIndex downTo 0) {
val item = dList[m]
val newValueC = maxUIBarStartY - item.currPos * heightDiv - (animateHValue * item.diffPos)
val newAnimateValue = if (newValueC < minUIBarStartY) {
minUIBarStartY
} else if (newValueC > maxUIBarStartY) {
maxUIBarStartY
} else newValueC
//绘制Y上刻度文案
if (start) drawText(
textMeasurer = textMeasurer, text = item.title, topLeft = Offset(it.offsetxLable, newAnimateValue), style = TextStyle(color = it.titleColor ?: item.color)
)
val newWidth = if (m == dList.size - 1 || availableWidth < (item.value + (item.diffValue / 100f) * animateWValue) * xAbs) {
availableWidth
} else (item.value + (item.diffValue / 100f) * animateWValue) * xAbs
//绘制条型柱状
drawRect(
color = item.color, topLeft = Offset(it.offsetx, newAnimateValue), size = Size(newWidth, barWidth)
)
drawImage(mapImage[item.title]!!.bitmap, dstOffset = IntOffset((it.offsetx + newWidth).toInt(), newAnimateValue.toInt()), dstSize = IntSize(barWidth.toInt(), barWidth.toInt()))
if (start) drawText(
textMeasurer = textMeasurer, text = " ${item.title}:${it.getTextValueFormat(item.value + (item.diffValue / 100f) * animateWValue)}", topLeft = Offset(it.offsetx + barWidth + newWidth, newAnimateValue), style = TextStyle(color = it.titleColor ?: item.color)
)
}
if (start) drawText(
textMeasurer = textMeasurer, text = "${drawKeyNo}", topLeft = Offset(
width - it.offsetx - it.keyTextOffsetX, height - it.offsety - it.keyTextOffextY,
), style = style
)
}
}
四、总结:
本文重点介绍了数据可视化趋势发展
及重点介绍:用Compose是如何一步一步实现出,视频效果的
后续会进行整理项目,同时会介绍Android下怎么代码实现录屏,及怎么通过代码将视频处理成GIf等等