ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MPAndroidChart滑动柱状图:视口控制与圆角渲染实践

MPAndroidChart滑动柱状图:视口控制与圆角渲染实践

简介:围绕 MPAndroidChart 实现可滑动柱状图的实战源码工程,面向已掌握 Android 基础、需在应用中展示大量数据并横向浏览的开发者。针对官方库仅提供缩放与拖拽、缺少原生滚动能力的限制,工程演示借助手势监听、视口缩放区间与 X 轴范围控制模拟顺滑滑动,并兼顾数据分段加载与性能优化。

压缩包共 27 个文件、约 550KB,以 7 个 xml 布局配置、4 个 java 源文件为核心,辅以 3 个 gradle 构建脚本、2 个 jar 依赖及截图、属性与混淆配置,形成可导入 Android Studio 运行的最小示例,便于对照修改和二次集成。

已有 1826 人学习下载。读者可获取图表初始化、数据集组织、缩放参数设定与触摸事件处理的完整代码,并体会大数据量下的动态加载思路,为柱状图交互改造提供可复用参考,适合需要定制图表滑动体验的 Android 开发者查阅。

1. 六十根柱子挤成条形码之后:这个滑动柱状图到底解决什么

后台报表页塞进六十个门店的月度销量,一屏画完,柱子细得像条形码,X 轴标签糊成一片黑,用户根本点不中自己要的那根。这是用 MPAndroidChart 做滑动的柱状图时最典型的起点:数据没错,是视口没管。核心诉求只有一句——一屏只显示 6 到 8 根,剩下的交给手指横向拖。

这份资源适合两类人:一是刚接图表需求、对着BarChart的几十个 setter 发懵的安卓开发;二是已经画出来、但卡在「拖不动、拖了弹回、标签错位、圆角没处加」这些细节上的熟手。下面按「装数据 → 开视口 → 调视觉 → 抽组件」的顺序拆,代码都能直接往工程里贴。

2. 图表初始化与数据装配:从 BarChart 控件到 BarData

BarChart默认状态很"素":有描述文字、有网格背景、有图例、有右侧 Y 轴,还有双击放大。这些开关如果不手动关掉,后面调视口会被各种干扰。所以第一步先把图表本身调成一个干净容器,再灌数据。

2.1 依赖、布局与那八个必须手动打开的开关

MPAndroidChart 没有发布到 Maven Central,走的是 JitPack 仓库,先确认仓库地址加在模块能解析到的地方:

// settings.gradle 里的仓库声明 dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://jitpack.io") } } } // app/build.gradle dependencies { implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' }

布局里直接当普通 View 用,高度给固定值或者0dp + weight都行,别用wrap_content——图表内部按内容区高度算柱子坐标,wrap_content在某些机型上会塌成一条线:

<com.github.mikephil.charting.charts.BarChart android:id="@+id/barChart" android:layout_width="match_parent" android:layout_height="220dp" android:layout_marginHorizontal="12dp" />

初始化的这批设置我一般抽成一个方法,跟数据解耦,换页面直接复用:

private fun setupChart(chart: BarChart) { // 一、视觉噪音全关 chart.description.isEnabled = false // 右下角那行 "Description Label" chart.legend.isEnabled = false // 单组数据不需要图例 chart.setDrawBarShadow(false) // 柱子后面的浅色影子 chart.setDrawGridBackground(false) // 整块背景网格 chart.setDrawBorders(false) // 外边框 chart.setDrawValueAboveBar(true) // 数值画在柱子顶上 // 二、手势先全开,第 3 章再收窄 chart.setTouchEnabled(true) chart.isDoubleTapToZoomEnabled = false // 双击会打乱当前视口,必须关 // 三、视口:一屏最多 7 根柱,最少缩到 4 根 chart.setVisibleXRange(4f, 7f) // 四、留白,否则最右一根柱子和柱子顶上的数值会被裁掉 chart.setExtraOffsets(8f, 12f, 12f, 8f) chart.setFitBars(true) }

几个参数值得单独说清楚。setVisibleXRange(min, max)的单位是"X 轴上的数值跨度",不是像素,也不完全是柱子根数,后面 3.1 会讲怎么换算。setDrawValueAboveBar(true)打开后每根柱子头上会顶一个数字,柱子一多就重叠,通常配setDrawValues(false)在数据集层面关掉,只在放大时才显示。setFitBars(true)的作用是让首尾柱子的宽度自适应内容区,避免最后一根只露半个。

提示:setDrawBorders(false)只是去掉外框,X 轴那条基线归xAxis.setDrawAxisLine()管,两处别混。

2.2 BarEntry 到 BarData:单组和多组数据怎么装进去

数据结构是两层:BarEntry是单个值,BarDataSet是一组值加样式,BarData是容器。X 轴坐标必须自己按 0、1、2 递增填进去,索引就是后面标签格式化的依据。

// 单组:60 个门店的销量 val values = listOf(120f, 340f, 88f, /* ... 共 60 个 */) val entries = values.mapIndexed { index, v -> BarEntry(index.toFloat(), v) // x = 索引,y = 数值 } val set = BarDataSet(entries, "销量").apply { color = Color.parseColor("#4C8DFF") setDrawValues(false) // 60 根柱子不画数值 highLightAlpha = 60 // 点击高亮叠加透明度 highLightColor = Color.parseColor("#FF6B35") isHighlightEnabled = true } val data = BarData(set).apply { barWidth = 0.55f // 单根柱占 X 轴一格的宽度比例 } chart.data = data chart.notifyDataSetChanged() // 告诉图表底层数据变了 chart.moveViewToX(0f) // 视口回到最左 chart.invalidate() // 触发重绘

notifyDataSetChanged()和invalidate()是两件事,很多人只写后者,结果是视口范围没重算,滑到一半空白。另外moveViewToX(0f)一定要放在setData之后,否则当时还没有数据条目,调用等于没发生。

分组柱状图(比如销量 + 退货两列)不需要改 X 值,还是 0、1、2,把两组塞进同一个BarData就行,宽度会自动均分:

val groupData = BarData(setSales, setReturn).apply { barWidth = 0.6f // 这是"一组两根"的总宽度,内部均分成各 0.3 // 想在一组内部拉缝隙,用 groupBars 指定组间距和柱间距 // groupBars(0f, 0.3f, 0.02f) } chart.data = groupData chart.notifyDataSetChanged() chart.invalidate()

groupBars(fromX, groupSpace, barSpace)里三个参数必须满足groupSpace + barSpace * n + barWidth * n <= 1,不满足会直接抛异常,这是分组图最常崩的地方。

2.3 坐标轴三件套:granularity、labelCount、ValueFormatter

X 轴是滑动的柱状图里最容易出错的一根轴。默认它按浮点数等分去取标签位置,60 根柱子会出现0.33、1.67这种鬼畜刻度。三个参数锁死它:

chart.xAxis.apply { position = XAxis.XAxisPosition.BOTTOM setDrawGridLines(false) // 竖向网格线会让 60 根柱子变成格子布 setDrawAxisLine(true) setAvoidFirstLastClipping(true) // 首尾标签不贴边被裁 granularity = 1f // 刻度步长固定为 1,一根柱一个刻度 isGranularityEnabled = true // 必须打开,否则 granularity 不生效 setLabelCount(7, false) // 一屏最多显示 7 个标签,false = 允许不满 textSize = 11f textColor = Color.parseColor("#666666") }

setLabelCount(count, force)的第二个参数是"是否强制",传true时即使刻度不够也会硬塞满,容易和granularity=1f冲突出现半格标签,实践中一律传false。

左侧 Y 轴按"零基线 + 整数刻度 + 淡化网格"配,右侧 Y 轴直接关掉:

chart.axisRight.isEnabled = false chart.axisLeft.apply { axisMinimum = 0f // 柱子必须从 0 起,否则视觉比例骗人 setDrawGridLines(true) gridColor = Color.parseColor("#EEEEEE") gridLineWidth = 0.8f setLabelCount(5, true) // 数值可能是 12000 这种,格式化成一万一格,标签短一半 valueFormatter = object : ValueFormatter() { override fun getFormattedValue(value: Float): String = if (abs(value) >= 10000f) "${(value / 10000f).toInt()}万" else value.toInt().toString() } }

3. 滑动视口:setVisibleXRange 与手势开关怎么配

柱子画出来不等于能滑。BarChart的滑动本质是"内容区宽度固定 + X 轴总跨度大于视口跨度",中间靠 ViewPortHandler 做平移。理解这一点,后面所有"拖不动"的问题都能自己定位。

3.1 一屏显示几根柱子,是算出来的不是猜的

X 轴上的跨度单位是数据索引。60 根柱子,索引从 0 到 59,那 X 轴总跨度就是 60 左右。setVisibleXRange(minXRange, maxXRange)里的maxXRange决定了视口一次能看到多少跨度,所以:

val barCount = 60 // 数据条目数 val perScreen = 7 // 希望一屏看到几根 chart.setVisibleXRange(4f, perScreen.toFloat()) // 等价写法:chart.setVisibleXRangeMaximum(7f)

maxXRange一旦大于等于总跨度,图表就不会滑了——它觉得一屏能装下。所以数据量少(比如只有 5 条)时漏出"滑不动"其实是正常表现,不用调。

minXRange控制的是往另一个方向走的极限,也就是最多能放大到几根。传4f意味着用户手指一划最多缩到 4 根柱子的范围,不能再放大。

视口初始位置三种方法,语义不一样:

chart.moveViewToX(0f) // 让 x=0 这根柱子对齐内容区左边缘,最常用 chart.moveViewToAnimated(0f, chart.axisLeft.axisMaximum, YAxis.AxisDependency.LEFT, 800) chart.centerViewTo(20f, 0f, YAxis.AxisDependency.LEFT) // 让第 20 根居中

moveViewToAnimated要注意:如果同时开了animateY,两个动画会抢帧,表现为柱子往上长一半就停住。血泪经验是入场动画和数据初始位置只留一个。

// 数据量刷新后,视口范围要重新算一遍 fun updateData(newValues: List<Float>) { val entries = newValues.mapIndexed { i, v -> BarEntry(i.toFloat(), v) } (chart.data.getDataSetByIndex(0) as? BarDataSet)?.let { it.values = entries // 直接换 values } chart.data.notifyDataChanged() // 数据集层通知 chart.notifyDataSetChanged() // 图表层通知 chart.setVisibleXRange(4f, 7f) // 重点:重设视口范围 chart.invalidate() }

setVisibleXRange在setData之后如果不重设,某些版本会沿用上一次的绝对值,数据从 60 条降到 8 条时会卡在旧范围里出不来。

3.2 拖动、捏合、双击:手势开关的正确顺序

滑动的柱状图只需要"横向拖",其他手势全关。这几个 setter 之间是有顺序依赖的:

chart.setTouchEnabled(true) // 总开关,最后关它等于全关 chart.setScaleXEnabled(false) // 不要用 setScaleEnabled(false) chart.setScaleYEnabled(false) chart.setPinchZoom(false) // 双指同时缩放 X/Y,这里不需要 chart.setDragEnabled(true) // 放在缩放之后开 chart.setDragDecelerationEnabled(true) chart.setDragDecelerationFrictionCoef(0.92f) // 默认 0.9,调大一点惯性更长 chart.isDoubleTapToZoomEnabled = false chart.setHighlightPerDragEnabled(false) // 拖动过程中不要触发高亮

setScaleEnabled(false)这个总开关在不同小版本里表现不一致,有的会把内部触摸标志一起置掉,导致后面setDragEnabled(true)也不生效。分开关 X、Y 是稳的做法。setDragDecelerationFrictionCoef的取值区间是 0 到 1,越接近 1 惯性滑得越远,滑太快飞出去两屏反而不好点,0.9 到 0.95 之间手感比较稳。

注意:setHighlightPerDragEnabled(false)不设的话,拖动时手指底下那根柱子会一直闪高亮,松手后高亮还留在原地,看起来像渲染错位。

3.3 视口变化回调:滑到边界时加载下一页

数据超过一百条一次性塞进图表会明显卡,尤其是每根柱都带渐变的场景。常见做法是分页,滑到右边界前预取下一页。视口位置用lowestVisibleX和highestVisibleX读:

chart.setOnChartGestureListener(object : OnChartGestureListener { override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) { // 距离右边界还剩 2 根柱子时开始预加载 val last = chart.highestVisibleX val total = chart.data.entryCount.toFloat() if (last >= total - 2 && !isLoading) { isLoading = true loadNextPage() // 请求下一页,成功后 appendPage 再重置 isLoading } } // 其余回调按需实现,不用的留空 override fun onChartGestureStart(me: MotionEvent?, gesture: ChartGesture?) {} override fun onChartGestureEnd(me: MotionEvent?, gesture: ChartGesture?) {} override fun onChartLongPressed(me: MotionEvent?) {} override fun onChartDoubleTapped(me: MotionEvent?) {} override fun onChartSingleTapped(me: MotionEvent?) {} override fun onChartFling(me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float) {} override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {} })

onChartTranslate在手指移动和惯性滑动时都会回调,频率很高,里面不要做重活,只打标记。追加数据时千万别用moveViewToX(0f)归位,用户会看着自己刚翻到的地方被拽回开头——正确做法是记下当前lowestVisibleX,追加完再moveViewToX(savedX)。

4. 避坑与排查:滑动柱状图最容易翻车的五个现场

这套组合我见过太多人卡在同一个地方,现象都很有迷惑性。下面五条按现场记录,照着对号入座基本能省半天。

4.1 数据和视口相关的三个坑

坑一:数据加了,一屏还是全挤在一起,滑不动。

现象是 60 条数据渲染出来柱宽不到 2dp,手指左右划毫无反应。原因通常是setVisibleXRange写在了setData之前,那时entryCount还是 0,视口范围按空数据算出来等于没设;也有的是被后面的invalidate()覆盖。解决办法是把视口设置挪到setData+notifyDataSetChanged()之后,再补一次invalidate(),并且在数据刷新方法里重复调用setVisibleXRange。

坑二:能滑,但一松手弹回第一根。

手指一放开,视口自动回到 x=0,看起来像被吸回去。多半是animateY()或者某个"刷新完成"回调里又调了moveViewToX(0f)。另一种是每次notifyDataSetChanged()之后都无条件归位。解决思路是加个布尔量区分首屏和增量刷新:首次加载归位,后续刷新记住chart.lowestVisibleX并在数据变更后moveViewToX回这个值。

坑三:X 轴标签和柱子对不上,第 3 根柱子底下写的是"门店 5"。

现象是滑动之后标签整体偏移,越往后差得越多。原因是给xAxis设了granularity但不是 1,或者用的是setLabelCount(7, true)强制塞满,图表按等分抽稀取标签,抽稀位置和整数索引错开。解决:granularity = 1f且isGranularityEnabled = true,setLabelCount第二个参数传false,标签内容交给ValueFormatter按取整后的索引去数组里取,取不到就返回空串。

4.2 手势和渲染相关的两个坑

坑四:关了缩放之后,拖动也一起失灵了。

现象是isScaleXEnabled = false之后完全不能滑。前面 3.2 提过,setScaleEnabled(false)是连带开关,会波及拖动标志,而且不同版本行为不一致。解决是拆开只关 X/Y 两个方向的缩放,setDragEnabled(true)放在这些之后调用;如果还是不行,用setOnTouchListener兜底手动拦截横向MotionEvent,虽然丑但一定能动。

坑五:换了自定义 Renderer,图表白屏或直接崩。

现象是chart.renderer = RoundedBarRenderer(...)之后什么都不显示,或者ClassCastException。原因有三个方向:一是setRenderer写在setData之前,渲染器初始化时拿不到BarData;二是自定义 Renderer 里直接强转dataSet as BarDataSet,但分组图里传进来的是IBarDataSet的其他实现;三是父类里mBarBuffers在某些版本是 private,子类根本拿不到已经被填好的柱子坐标缓冲。解决:先setData再setRenderer;类型判断用if (dataSet is BarDataSet);拿不到缓冲就在initBuffers里自己存一份引用,别硬着头皮反射。

提示:改渲染器之前先跑一遍原生图,确认数据、坐标轴、视口都对,再替换渲染器。不然白屏的时候你分不清是渲染器写错还是数据为空。

5. 视觉与交互打磨:标签不重叠、柱子带圆角、点击不打架

功能跑通之后剩下的全是观感问题。滑动柱状图有三个地方最影响"这图表是不是专业"的判断:X 轴标签密度、柱子的圆角与渐变、点击高亮和拖动手势的共存。

5.1 X 轴标签:60 个门店名怎么只显示 7 个

setLabelCount(7, false)只管"图表打算画几个标签",具体每个位置显示什么由格式化器决定。数据多的时候,直接返回索引对应的名字会出现两个标签挨在一起的重叠。稳妥做法是隔位返回空串:

val labels = listOf("望京店", "中关村店", /* ... 共 60 个 */) val perScreen = 7 val step = ceil(labels.size / perScreen.toFloat()).toInt() // 60/7 ≈ 9 chart.xAxis.valueFormatter = object : ValueFormatter() { override fun getFormattedValue(value: Float): String { val i = value.toInt() if (i < 0 || i >= labels.size) return "" // 只在整倍数索引上出标签,其余返回空 return if (i % step == 0) labels[i] else "" } }

step必须是整数,用ceil是为了保证标签数量不会超过一屏 7 个。如果门店名长达六七个字,还得配合旋转:

chart.xAxis.labelRotationAngle = -35f // 逆时针斜放,比水平排省一半宽度 chart.axisLeft.setDrawAxisLine(false) // 旋转后左侧会多出一条竖线,视觉上很脏

旋转角度别超过 -45 度,否则标签高度吃掉图表区,柱子上半截被挤扁。旋转之后一定要重设setExtraOffsets的底部留白,不然最后一排字被裁。

5.2 换掉 BarChartRenderer,给柱子加圆角和渐变

原生BarDataSet只提供纯色、可选的setGradientColor,没有圆角。要做圆角柱只能替换渲染器。核心思路是:让父类照常算好每根柱子的矩形坐标,你在画的时候把drawRect换成drawRoundRect。

class RoundedBarRenderer( chart: BarChart, animator: ChartAnimator, viewPortHandler: ViewPortHandler, private val cornerRadius: Float ) : BarChartRenderer(chart, animator, viewPortHandler) { private val barRect = RectF() override fun drawDataSet(c: Canvas, dataSet: IBarDataSet, index: Int) { val trans = mChart.getTransformer(dataSet.axisDependency) // 父类已经算好的柱子缓冲:每 4 个 float 是一根柱 // 依次为 left / top / right / bottom val buffer = mBarBuffers[index] buffer.setPhases(mAnimator.phaseX, mAnimator.phaseY) buffer.setBarWidth(mChart.barData.barWidth) buffer.setInverted(mChart.isInverted(dataSet.axisDependency)) buffer.feed(dataSet) trans.pointValuesToPixel(buffer.buffer) val paint = mRenderPaint paint.style = Paint.Style.FILL // 渐变按整个内容区高度建一次,不要每根柱子重建,否则 60 根就是 60 次分配 paint.shader = LinearGradient( 0f, viewPortHandler.contentTop, 0f, viewPortHandler.contentBottom, Color.parseColor("#4C8DFF"), Color.parseColor("#8FBEFF"), Shader.TileMode.CLAMP ) var j = 0 while (j < buffer.size()) { val left = buffer.buffer[j] val top = buffer.buffer[j + 1] val right = buffer.buffer[j + 2] val bottom = buffer.buffer[j + 3] // 屏幕外的柱子直接跳过,滑动时能省掉一批绘制 if (left > viewPortHandler.contentRight || right < viewPortHandler.contentLeft) { j += 4 continue } barRect.set(left, top, right, bottom) c.drawRoundRect(barRect, cornerRadius, cornerRadius, paint) j += 4 } paint.shader = null // 用完复位,否则后续绘制全被染上渐变 } }

几个关键点解释一下。mChart.getTransformer()负责把数据坐标转成屏幕像素,不同AxisDependency(左轴/右轴)用的转换器不一样,所以要从dataSet上取。mBarBuffers[index]是父类在initBuffers阶段按数据集索引建好的缓冲区。最后必须把paint.shader置空,否则后面的高亮框、数值文字都会带上这段渐变。

挂载时机很关键,先数据后渲染器:

chart.data = data chart.notifyDataSetChanged() chart.setVisibleXRange(4f, 7f) chart.renderer = RoundedBarRenderer(chart, chart.animator, chart.viewPortHandler, 6f) chart.invalidate()

chart.animator和chart.viewPortHandler都是公开的,不用反射。圆角半径给 6f 左右比较自然,柱子宽度只有 0.55 格时圆角超过柱宽一半会画成胶囊,反而难看。

5.3 高亮、点击与拖动的手势冲突

滑动柱状图里点击和拖动共用一次触摸。默认行为是手指按下就高亮,滑走之后高亮留在原地,用户会以为点错了。三个开关配一下:

chart.setHighlightPerDragEnabled(false) // 拖动不触发高亮 chart.isHighlightPerTapEnabled = true // 点击才高亮 chart.highlightValue(null) // 主动清除当前高亮 chart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry?, h: Highlight?) { val i = e?.x?.toInt() ?: return showDetail(labels.getOrNull(i) ?: "", e.y) // 弹自己的详情卡片 } override fun onNothingSelected() { /* 空实现即可 */ } })

真正的坑在 MarkerView。如果用了MarkerView做浮动提示,它在滑动过程中不会自动跟随,会卡在屏幕中间。处理办法是在onChartTranslate里主动chart.highlightValue(null)清掉高亮,让 MarkerView 跟着消失:

override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) { if (chart.highlighted.isNotEmpty()) { chart.highlightValue(null) // 滑动即取消高亮,浮层跟着散掉 } }

还有一处细节:highLightColor设的是叠加色,最终颜色是高亮色和柱子色按highLightAlpha混出来的。想要"点中变深"的效果,把highLightColor设成深蓝、highLightAlpha给 60 到 90,别设 255,否则整根柱子被纯色覆盖,渐变全没了。

6. 二次开发:把可滑动柱状图收成一个组件

同一个人连着做三个报表页之后,一定会干同一件事:把上面这一坨配置抽成通用组件。我的做法是继承BarChart而不是包一层FrameLayout,这样布局里能直接用它,也不用往外转发一堆方法:

class ScrollableBarChart @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : BarChart(context, attrs) { private var labelStep = 1 fun render(labels: List<String>, values: List<Float>, perScreen: Int = 7) { labelStep = ceil(labels.size / perScreen.toFloat()).toInt().coerceAtLeast(1) val entries = values.mapIndexed { i, v -> BarEntry(i.toFloat(), v) } // 配置视口 -> 装数据 -> 挂渲染器 -> 归位,顺序不能换 setVisibleXRange(4f, perScreen.toFloat()) data = BarData(BarDataSet(entries, "").apply { setDrawValues(false) }) notifyDataSetChanged() renderer = RoundedBarRenderer(this, animator, viewPortHandler, 6f) moveViewToX(0f) invalidate() } }

外部只需要chart.render(labels, values)一行,step在内部算好交给格式化器。这样改一个图表配色,五个页面同时生效,比复制粘贴强太多。二次开发时最容易踩的是继承BarChart后忘了在init块里调setupChart(this),导致子类实例默认带描述文字和双击缩放,而父类那套配置只写在了 Activity 里。

改造完之后,我一般会跑一个三档数据量的验证:3 条、60 条、600 条。3 条时不出现滑动、标签全显示;60 条时一屏 7 根、拖到最右能看到最后一根完整柱;600 条时滑动不卡顿、屏幕外柱子被跳过绘制。三档都过再合代码,比在真机上肉眼看强得多。

从那以后我每次接图表需求,都强制自己先把"数据量、一屏几根、最右一根是否完整"这三个数写进注释,再去碰那几十个 setter。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进