Motion and viewport Chapter 14 of 35

Every touch gesture a chart understands, how to switch each one off, and the listeners that tell you what the user did.

Switch gestures on and off#

All interaction runs through one flag on every chart type:

chart.isTouchEnabled = false   // the chart ignores touch completely

With touch enabled, each gesture has its own property. The drag and scale properties live on the charts with axes (LineChart, BarChart, HorizontalBarChart, ScatterChart, CandleStickChart, BubbleChart, CombinedChart).

PropertyMeaningDefault
isTouchEnabledAll touch interaction, on every chart typetrue
isDragXEnabledPan horizontallytrue
isDragYEnabledPan verticallytrue
isDragEnabledReads true when either axis can be dragged, writes bothtrue
isScaleXEnabledZoom the x axis by gesturetrue
isScaleYEnabledZoom the y axis by gesturetrue
isScaleEnabledReads true when either axis can be scaled, writes bothtrue
isPinchZoomEnabledA pinch scales both axes togetherfalse
isDoubleTapToZoomEnabledA double tap zooms intrue
isHighlightPerTapEnabledA tap selects the nearest value, on every chart typetrue
isHighlightPerDragEnabledDragging moves the selection while the chart cannot pantrue
isRotationEnabledRotate a pie or radar chart by draggingtrue

A chart that should only show data, with no gestures at all, is the common case. The example app's design charts do exactly this:

chart.isScaleEnabled = false
chart.isDragEnabled = false
chart.isDoubleTapToZoomEnabled = false

Dragging and zooming#

Dragging pans the content once the chart is zoomed in. A single finger has to travel about 3 dp before the touch counts as a drag rather than a tap.

Zooming works in one of two ways. With isPinchZoomEnabled false, which is the default, a two finger gesture that is mostly horizontal zooms the x axis and a mostly vertical one zooms the y axis, so you can stretch one axis without the other. Set it to true and a pinch scales both axes by the same factor.

chart.isPinchZoomEnabled = true

A double tap zooms in by a factor of 1.4 around the tapped position, on each axis that has scaling enabled.

Two extra properties let the content be dragged a little past its bounds, which feels softer at the edges:

chart.dragOffsetX = 20f   // dp of overscroll left and right
chart.dragOffsetY = 20f   // dp of overscroll top and bottom

Both default to 0. They also matter for highlighting per drag: the chart only moves the selection instead of panning when it is fully zoomed out and has no drag offset.

Zooming and scrolling from code, including limits on how far the user may zoom, is covered in Modifying the viewport.

Fling and deceleration#

Lifting the finger after a fast drag keeps the chart scrolling and slows it down.

PropertyMeaningDefault
isDragDecelerationEnabledKeep scrolling after the finger liftstrue
dragDecelerationFrictionCoefHow slowly the scroll loses speed0.9

The friction coefficient is multiplied into the velocity on every frame. 0 stops the chart at once, values near 1 let it coast for a long time. Assigned values are clamped into 0 until 0.999.

chart.dragDecelerationFrictionCoef = 0.95f

Pie and radar charts use the same two properties for their rotation, so a flick keeps the chart spinning.

Rotating pie and radar charts#

PieChart and RadarChart are rotated by dragging instead of panned.

chart.isRotationEnabled = true
chart.rotationAngle = 0f     // where the first slice starts

Angles are degrees, 0 at 3 o'clock, increasing clockwise. rotationAngle normalizes what you assign into 0 until 360; rawRotationAngle keeps the value you passed. The default is 270, which puts the first slice at the top.

To turn the chart from code, see spin() in Animations.

React to a selected value#

The shortest form takes a lambda:

chart.onValueSelected { entry, highlight ->
    Log.i("selected", "${entry.x} / ${entry.y} in set ${highlight.dataSetIndex}")
}

A second lambda handles the case where the selection is cleared:

chart.onValueSelected(
    onNothingSelected = { detailView.isVisible = false },
) { entry, _ ->
    detailView.isVisible = true
    detailView.text = entry.y.toString()
}

Both forms replace chart.onChartValueSelectedListener. Implement the interface yourself when one class handles several charts:

class ReportActivity : AppCompatActivity(), OnChartValueSelectedListener {

    override fun onValueSelected(e: Entry<*>, h: Highlight) { }

    override fun onNothingSelected() { }
}

chart.onChartValueSelectedListener = this

onNothingSelected fires when the user taps empty space, taps the selected value again, or the highlighted entry is no longer in the data. Calls to chart.highlightValue(...) report here as well unless you pass callListener = false. What the Highlight contains is described in Highlighting values.

React to gestures#

OnChartGestureListener reports the raw gestures. Every method has an empty default body, so implement only the ones you need.

CallbackWhen it fires
onChartGestureStartA finger touches the chart
onChartGestureEndThe touch ends or is cancelled
onChartSingleTappedSingle tap, before the tap highlight is applied
onChartDoubleTappedDouble tap, before the double tap zoom is applied
onChartLongPressedLong press
onChartFlingFast swipe, with the velocity in pixels per second
onChartScalePinch or double tap zoom, with the scale factor per axis
onChartTranslateDrag, including the deceleration after a fling
chart.onChartGestureListener = object : OnChartGestureListener {

    override fun onChartTranslate(me: MotionEvent, dX: Float, dY: Float) {
        syncOtherChart(chart.lowestVisibleX, chart.highestVisibleX)
    }

    override fun onChartScale(me: MotionEvent, scaleX: Float, scaleY: Float) {
        syncOtherChart(chart.lowestVisibleX, chart.highestVisibleX)
    }
}

The start and end callbacks also receive the gesture that was recognised, as a ChartTouchListener.ChartGesture: NONE, DRAG, X_ZOOM, Y_ZOOM, PINCH_ZOOM, ROTATE, SINGLE_TAP, DOUBLE_TAP, LONG_PRESS or FLING.

Several listeners on one chart#

onChartValueSelectedListener and onChartGestureListener each hold a single listener, so assigning a second one replaces the first. When something else has to observe the chart without taking that slot away from your app, add it to one of the two lists instead:

chart.valueSelectedListeners += analyticsListener
chart.gestureListeners += analyticsListener

The single listener is called first, then the list entries in order. This is how the Compose module observes a chart while your own listener keeps working.

Charts inside a scrolling parent#

A chart that is dragged inside a ScrollView or a RecyclerView fights the parent for the gesture. Charts in lists and scrolling screens covers the whole situation. The chart already asks the parent to stop intercepting while a drag or zoom is running, and you can do the same from your own touch handling:

chart.disableScroll()   // the gesture stays with the chart
chart.enableScroll()    // the parent may intercept again

Interaction in Compose#

The Compose charts report the same events through ChartState, so you read the selection as state instead of registering a listener:

val state = rememberChartState()

LineChart(
    data = lineData,
    state = state,
    modifier = Modifier.fillMaxWidth().height(220.dp),
)

val selected = state.selectedEntry
Text(if (selected == null) "Tap a point" else "Selected ${selected.y}")

state.selectedHighlight, state.lowestVisibleX, state.highestVisibleX, state.zoomX, state.zoomY and state.rotationAngle follow the user in the same way. To drive the chart from Compose, call state.highlight(x), state.clearHighlight(), state.zoomIn(), state.fitScreen() or state.moveViewToX(value). Everything else about the module is in Jetpack Compose.