全网最火视频,Compose代码写出来,动态可视化趋势视频,帅到爆

2,873 阅读8分钟

Compose代码直接写出全网最火的可视化动态趋势视频,帅到爆
Compose实现应该是全网唯一了

互联网智慧大数据分析,图表,和可视化趋势是一种趋势,分析的利器
可视化图表系列如下:
(一)Compose曲线图表库WXChart,你只需要提供数据配置就行了
(二)Compose折线图,贝赛尔曲线图,柱状图,圆饼图,圆环图。带动画和点击效果
(三)全网最火视频,Compose代码写出来,动态可视化趋势视频,帅到爆
(四)全网最火可视化趋势视频实现深度解析,同时新增条形图表
(五)庆元旦,出排名,手撸全网火爆的排名视频,排名动态可视化
(六)Android六边形战士能力图绘制,Compose实现
(七)Android之中美PK,赛事PK对比图Compose实现
(八)Android之等级金字塔之Compose智能实现

ezgif-6-05a8ffea4a.gif

上面GIF效果图:是Android Studio模拟器上App中

Compose动画录屏成视频转化后的GIF效果,本身是带音乐背景的。

Android Studio模拟器及项目截图如下: img_v3_02hl_407b1b08-87f4-4418-ae9f-25be6d79e59g.jpg

一、前言

互联网时代发展到今天,数据可视化已经成了当下非常流行的一种形式,我们会经常看到各国GDP随着年份增长的趋势,应用到我们自己业务中,可能也会存在,比如:

  1. 各大门店销售单量近3个月走势对比
  2. 各大医院近3个月每日就诊人数走势对比
  3. 各大城市地铁流量近60个客流量对比
  4. 各大高校近30年来每年排名走势对比

二、可视化数据发展的几个阶段

  1. 原始阶段:只能看数据库,或者只能手动录入Excel表格,或者到处表格看,缺点数据不实时
  2. 图形阶段:将数据做成折线图、曲线图、条形图、柱状图,圆饼图,圆环图等,缺点图表没有动态
  3. 进阶阶段:将数据用工具或网站做成可视化动态趋势视频,缺点数据无法动态,每次得拿数据来制作
  4. 高级阶段:用动画配上音乐用代码实现,数据可以直接更新,图表也有走势动态化趋势,貌似JS实现最多了,Android上面,或者用Compose实现的到目前应该还没有的,缺点:需要改动画走势动画,得改源码,得发布新版
  5. 资深阶段:上面第4阶段必须实现,同时需要修改动画,可以动态发布。app或者程序无需发布新版

本文主要介绍用Compose代码怎么实现到上面第4阶段
第5阶段我是可以轻松实现的,可以参考我前面介绍的文章:
大型项目架构:全动态插件化+模块化+Kotlin+协程+Flow+Retrofit+JetPack+MVVM+极限瘦身+极限启动优化+架构示例+全网唯一
本文 Demo工程 我就不做成全动态化项目工程了

三、Compose代码实现上面效果

  1. 上面整个背景是一张图片,已经做成了可配置网络图片了,其他全是用Canvas绘制出来的
  2. 动态数据model类可配置入下:
val chatHBarModel = DynamicModel(arrayKey, mapList, mapImage).apply {
    barWidth = toDp(25f) //条形宽度 isAutoBarWidth为false 才可用
    yCount = 10 //y轴横线刻度数 ,包含 0,比如05 设置为 6
    xCount = 4 //X轴横线刻度数 ,包含 0,比如05 设置为 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如下:

787ce882-c386-4d6c-a700-82fe313c4d5f.jpeg
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()
    }
}
  1. 每个年份下面存着的特殊数据: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等等

感谢阅读:

欢迎 关注,点赞、收藏

这里你会学到不一样的东西