Motion and viewport Chapter 15 of 35
How to add and remove entries and data sets while a chart is on screen, what to call afterwards, and how to keep a fast feed drawing smoothly.
Nothing about a chart is fixed once you have set its data. You can push entries into a data set, append or drop whole data sets, and clear everything. One call tells the chart about it.
Add an entry#
There are two places to add from. Going through the data object is the usual one, because it knows which data set you mean and widens its own cached ranges at the same time.
val data = chart.data ?: return
data.addEntry(Entry(x, y), 0) // into the data set at index 0
chart.notifyDataSetChanged()
Going through the data set works too, and is what you do when you already hold a reference to it.
set.addEntry(Entry(x, y)) // appends at the end
set.addEntryOrdered(Entry(x, y)) // inserts so the entries stay sorted by x
chart.notifyDataSetChanged()
addEntry appends without looking at the x value. Entries must stay sorted by x ascending, because every lookup on a data set is a binary search over x. Use addEntryOrdered whenever the new x is not the largest one.
A whole new series is one call:
val set = LineDataSet(mutableListOf<Entry<Any?>>(), "Live").apply {
color = Color.BLUE
lineWidth = 2f
isDrawCirclesEnabled = false
isDrawValuesEnabled = false
}
data.addDataSet(set)
chart.notifyDataSetChanged()
Remove data#
On a data set:
| Call | Removes |
|---|---|
removeFirst() | the entry at index 0 |
removeLast() | the entry at the end |
removeEntry(e) | that exact entry, matched by reference |
removeEntry(index) | the entry at a list index |
removeEntryByXValue(x) | the entry whose x is nearest to x |
clear() | every entry, keeping the data set |
removeFirst, removeLast, removeEntry(e) and removeEntryByXValue return false when there was nothing to remove. removeEntry(index) throws IndexOutOfBoundsException for an index outside the entries, and clear() returns nothing.
On the data object:
| Call | Removes |
|---|---|
removeEntry(e, dataSetIndex) | that entry from the data set at the index |
removeEntry(xValue, dataSetIndex) | the entry nearest to xValue in that data set |
removeDataSet(set) | that data set |
removeDataSet(index) | the data set at the index |
clearValues() | every data set, keeping the data object |
On a data set,removeEntry(index)takes a list index andremoveEntryByXValue(x)takes a value. On the data object the float overloadremoveEntry(xValue, dataSetIndex)is the one that takes a value.
What to call afterwards#
One call on the chart:
chart.notifyDataSetChanged()
It recomputes the data ranges, rebuilds the renderer buffers, recalculates both y axes and the x axis, rebuilds the legend, recalculates the offsets and redraws. There is no separate invalidate() to make.
The one case that needs a second call is changing a data set's entry list in place, without going through addEntry or removeEntry:
set.entries.add(Entry(x, y)) // the list is modified directly
set.notifyDataSetChanged() // the set recomputes its own x and y range
chart.notifyDataSetChanged()
Assigning a new list does not need that, because the setter recomputes the range for you:
set.entries = newEntries
chart.notifyDataSetChanged()
Version 3.x needednotifyDataSetChanged()on the data set,notifyDataChanged()on the data object,notifyDataSetChanged()on the chart and theninvalidate(). The chart call now does all four steps.
Keep a scrolling window#
A live chart normally shows the last n values and scrolls as new ones arrive. Two calls do that.
chart.setVisibleXRangeMaximum(120f) // at most 120 x units on screen
chart.moveViewToX(data.entryCount.toFloat()) // left edge at the newest value
setVisibleXRangeMaximum works out a minimum zoom factor from the x axis range at the moment you call it. When the data keeps growing, that factor no longer matches, and the visible window slowly widens. So call it again after every batch of new entries rather than once at setup. Both calls need data to be set first.
moveViewToX runs as a viewport job and redraws by itself, so nothing follows it. Modifying the viewport covers the rest of these calls.
The realtime pattern#
This is the loop the example app's RealtimeLineChartActivity uses, one entry per tick:
private fun addEntry() {
val data = chart.data ?: return
val set = data.getDataSetByIndex(0) ?: createSet().also { data.addDataSet(it) }
data.addEntry(Entry(set.entryCount.toFloat(), nextReading()), 0)
chart.notifyDataSetChanged()
chart.setVisibleXRangeMaximum(120f)
chart.moveViewToX(data.entryCount.toFloat())
}
Set the chart up once with an empty LineData so that chart.data is never null, and give the y axis a fixed range so the chart does not rescale on every reading:
chart.data = LineData()
chart.axisLeft.axisMinimum = 0f
chart.axisLeft.axisMaximum = 100f
chart.axisRight.isEnabled = false
Call addEntry on the UI thread. A background producer should hand its readings over with runOnUiThread or a coroutine on the main dispatcher; the chart is a View and does not synchronise its data.
Clear the chart#
| Call | Effect |
|---|---|
chart.clear() | drops the data object, clears the highlight and shows the no data text |
chart.clearValues() | removes every data set but keeps the data object |
data.clearValues() | the same, without the redraw |
set.clear() | empties one data set |
chart.clear() is the one to use when a screen is reused for a different source. chart.clearValues() keeps a live chart alive with an empty data object, ready for the next entry.
Large or fast data#
The renderer already draws only the entries inside the visible x range, so a large data set costs mostly memory, not draw time. Performance with large data goes through all of it; the short list of what costs draw time:
- Circles.
isDrawCirclesEnabled = falseon a line data set removes one draw call per point. - Value labels.
isDrawValuesEnabled = false. Labels are skipped automatically once the entry count reacheschart.maxVisibleCount(default 100) times the current zoom, but turning them off saves the check. - Curves.
LineDataSet.Mode.LINEARis cheaper thanCUBIC_BEZIER. - Line width. Thin lines draw faster; the value is clamped to 0 to 10 dp.
- Dashed lines. A dashed line goes onto the offscreen bitmap and is composed onto the chart, which costs an extra pass per frame.
For a feed that never stops, cap the entry count instead of letting it grow:
if (set.entryCount > 500) set.removeFirst()
If every frame matters, do not let the chart recompute the axes. isAutoScaleMinMaxEnabled = false is the default, and fixed axisMinimum and axisMaximum values on the y axis keep notifyDataSetChanged from reflowing the labels and the offsets.
Where to go next#
- Modifying the viewport for the scrolling window in detail.
- The ChartData class and the DataSet class for everything these two classes hold.
- Setting data for the entry and data set types of each chart.