-
Notifications
You must be signed in to change notification settings - Fork 408
Axes
An axis can be created with a fixed array of values or generators, which allow more dynamic behaviours. A chart can have an unlimited number of axes on each side (left/bottom/top/right).
It's probably helpful to keep in mind, before we start with more details, that the axes are entirely decoupled from the chart points. The axes don't care about the chart data. If the data happens to be in the axis range, it will be displayed, otherwise it will show somewhere outside of the axis boundaries (clipped, unless explicitly disabled).
The first step to make an axis, is to create an axis model. The axis model is basically a specification of our axis, which will be used to generate the axis layer that will be displayed on the screen. We can create an axis model using a fixed values array or generators.
This was the only possible way to define axes pre-0.6
. 0.6
works only with generators, but for convenience and backwards compatibility, it's still possible to use fixed values (they are mapped internally to generators).
Let's see how we create an axis with a fixed array of values. This is a very simple axis with the values 0, 2 and 4:
let labelSettings = ChartLabelSettings();
let values = [
ChartAxisValueInt(0, labelSettings: labelSettings),
ChartAxisValueInt(2, labelSettings: labelSettings),
ChartAxisValueInt(4, labelSettings: labelSettings),
]
let xModel = ChartAxisModel(axisValues: values, axisTitleLabel: ChartAxisLabel(text: "Axis title", settings: labelSettings))
It's possible to pass also values representing irregular intervals, e.g. (-1, 2, 10...). It's only important that they are sorted.
We used instances of ChartAxisValueInt
for our values. ChartAxisValueInt
extends ChartAxisValue
which is a thing that can be represented with a number (called "scalar"), and can produce an array of labels, which will be displayed in the chart. There are several default implementations of ChartAxisValue
, like ChartAxisValueInt
, ChartAxisValueDouble
(which has a number formatter) and ChartAxisValueDate
, among others. You of course can also create your own custom axis values, by extending ChartAxisValue
.
For more dynamic behaviour and performance, in particular when using zooming, we use generators. Let's create an axis model for an axis that goes from 0 to 100, with an interval size of 5:
let valuesGenerator = ChartAxisGeneratorMultiplier(5)
let labelsGenerator = ChartAxisLabelsGeneratorNumber(ChartLabelSettings())
let xModel = ChartAxisModel(firstModelValue: 0, lastModelValue: 100, axisTitleLabels: [ChartAxisLabel(text: "Axis title", settings: labelSettings)], axisValuesGenerator: valuesGenerator, labelsGenerator: labelsGenerator)
Here we don't pass anymore a values array but a specification of how we want these values to be generated. We see 2 new things, a values generator and a label generator. Let's look at these in more detail.
Values generators implement the protocol ChartAxisValuesGenerator
. The function of this class is to generate an array of Double
, based on the current state of the axis. Each time the visible area of the chart changes (during initialization or zooming and panning) the generator is called to generate axis values. It's up to the generator how to do this. In most cases it's wise to generate values only for the part of the axis which is currently visible (all which can be queried from the passed ChartAxis
), but that's not enforced.
SwiftCharts provides some implementations of ChartAxisValuesGenerator
:
This generates values for the visible part of the axis, which are multiples of the passed multiplier
. For example, a multiplier generator, used in an axis with the start values 0...100, will generate the values 0, 5, 10, 15, etc. When zooming, this generator will simply create a new axis value in the middle between two axis values when they become too far apart (2x the current interval size). For example, if we have an axis with the values 2, 4, 6 and we zoom in 2x, we get the values 2, 3, 4, 5, 6 with this mode. The next time we zoom in 2x, we get 2, 2.5, 3... etc.
This generator is a subclass of ChartAxisGeneratorMultiplier
which generates more user friendly numbers when zooming in.
This generator is also a subclass of ChartAxisGeneratorMultiplier
dedicated to dates.
This generator wraps a fixed values array. It allows to pass values at specific intervals which can't be automatically generated. It's also what's used for backwards compatibility with the fixed axis values array used in SwiftCharts pre-0.6
.
This is a subclass of ChartAxisValuesGeneratorFixed
with some additional functionality to avoid overlapping values.
You of course can also create your own ChartAxisValuesGenerator
implementations!
The job of a label generator is convert the numbers generated by ChartAxisValuesGenerator
into text - whatever it is, a formatted number, alphabetic string, etc. More specifically, it maps a Double
to a ChartAxisLabel
(which is just a wrapper for text and some text related state). The label generator doesn't have any awareness of the axis, it just receives individual numbers.
A label generator conforms to the protocol ChartAxisLabelsGenerator
. These are the label generators currently provided by SwiftCharts:
Not meant to be used directly, it holds some useful state, like a cache, so we just have to generate a label for a specific Double
only once.
Formats numbers, using a NumberFormatter
. Extends ChartAxisLabelsGeneratorBase
.
This generates shortened numbers, with kilo/mega/etc. suffixes. E.g. it displays 1K instead of 1000. Extends ChartAxisLabelsGeneratorBase
.
Formats dates, using a DateFormatter
.
Wraps an arbitrary function f: (Double) -> [ChartAxisLabel]
that's used to generate the labels.
Contains a dictionary with fixed mappings Double: [ChartAxisLabel]
. The main purpose of this is probably backwards compatibility, to map the fixed axis values array to the new api.
There are global axis settings, which are in ChartSettings
, and individual axis settings, which are passed to the axis models.
Global axis settings (ChartSettings)
labelsToAxisSpacingY
labelsToAxisSpacingX
labelsSpacing
axisTitleLabelsToLabelsSpacing
spacingBetweenAxesY
spacingBetweenAxesX
Additionally, with axisStrokeWidth
it's possible to define the stroke width of the axes.
Individual axis settings (ChartAxisModel)
Sets the line color of the axis
Empty initial space in pt between the leading/trailing border of the inner frame and the start/end of the axis. This of doesn't affect the axis line - only the displayed values. The contents of the chart of course are then "moved" inwards to match the modified axis values. Note that padding doesn't stay fixed during zooming or panning, it works like normal axis content, being thus limited only to the non transformed state of the chart.
There are several options to fine-tune the padding, in particular in relation with the axis labels. This is the enum code with some inline explanations, which is probably enough to understand how it works:
public enum ChartAxisPadding {
case label /// Add padding corresponding to half of leading / trailing label sizes
case none
case fixed(CGFloat) /// Set a fixed padding value
case maxLabelFixed(CGFloat) /// Use max of padding value corresponding to .Label and a fixed value
case labelPlus(CGFloat) /// Use .Label padding + a fixed value
}
In this image, for example, we use padding .label
for both axes, leading and trailing:
You also can control the presentation of the labels (and other possible content, in case you are using custom axes) by passing a clipContents
flag to the axis model. Here we see the effect of clipping on the leading adge of an x-axis:
Of course, when clipping is disabled, the axis value still disappears when the tick (i.e. the center of the label in most cases) goes outside of the visible part of the axis, but this is due to the axis value generator, which generates ticks only for the visible part of the axis (nothing hinders you though of implementing an axis value generator that doesn't do this, but it normally doesn't make sense).
During a chart transformation (zoom/pan), the labels on the y axis may have different lengths or there may be at times no visible x-axis labels (because they are too far apart and the value generator doesn't generate values in between). The space reservation mode determines how the chart inner frame reacts to this. These are the supported modes:
public enum AxisLabelsSpaceReservationMode {
case minPresentedSize /// Doesn't reserve less space than the min presented label width/height so far
case maxPresentedSize /// Doesn't reserve less space than the max presented label width/height so far
case fixed(CGFloat) /// Fixed value, ignores labels width/height
case current /// Reserves space for currently visible labels
}
For example, .current
will make the chart resize immediately (during the gesture) when there are deltas in the max length of currently presented labels. maxPresentedSize
will remember the max length presented so far and fix the reserved width by axis to that, minimizing the inner frame "jumps".
Warning: .current
mode currently can lead to misalignments between chart components (e.g. bars don't show correctly aligned with grid/labels), in following setup: 1) Labels (in particular of the y-axis) have different lengths, 2) Combining layers that use a per-element transform matrix (e.g. gridlines, axes, scatter points) with layers that inherit the transform matrix of content view - i.e. scalable UIView
based layers, like bars (for more details about transform management please visit this section) 3) The chart is zoomable/pannable, such that panning across labels with different lengths will cause the content frame continuously to resize. The solution in these cases is to use .maxPresentedSize
or .fixed(CGFloat)
mode instead.
You can specify a label rotation in the ChartLabelSettings
instance that's passed to the label generator or fixed axis values array. You can set a different rotation for individual labels, if for some reason this was necessary.
In some cases you may want to show something different than text as axis labels, or maybe together with the labels. The axis layers provided by SwiftCharts have the suffix "default" for a reason - you can implement your custom layers that render whatever you want, which have to conform to the ChartAxisLayer
protocol (Note: this protocol currently is coupled with ChartAxisLabelsGenerator
but this shouldn't be the case. If you need to implement custom axes and this is a problem please open an issue!).
Implementing a custom axis layer may be an interesting task, in particular if it can be added to SwiftCharts to provide such functionality in the future, but there's also a slightly easier way. You can use ChartPointsViewsLayer
to generate views for the axis values as if they were chart points. In order for this to work, you have to disable the clipping of the inner frame (ChartSettings.clipInnerFrame
). For it to work with zooming and panning you need a somewhat more complex version of this approach. For more details, see this issue.
OK now we have an axis model. What's next? We have to use it to 1. together with the other axes models, compute the chart inner frame, i.e. the space available to display chart points and 2. convert it to an axis layer, which is what we use to initialize the chart layers and also what we have to pass to the chart instance in order to be displayed.
Sounds a bit complicated, but there's a class called ChartCoordsSpace
which will do this work for us. What we have to do is simply to pass our axis models, together with the chart frame (i.e. the total frame available to display the chart, axes included), and let it generate the axis layers as well as compute the inner frame. There are also some additional helpers which make life a little more easier when working with standard configurations like one left y-axis and one bottom x-axis. We only have to use ChartCoordsSpace
directly when working with a non standard configuration, like e.g. 2 y-axis (more about this in the multiple axis section).
For example, to generate the axes layers and inner frame for a typical left y-axis, bottom x-axis chart we need this:
let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: chartFrame, xModel: xModel, yModel: yModel)
let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame)
(The chart settings are needed because they contain diverse spacing settings, which are involved in the calculation of the axis as well as the inner frame).
Now that we generated the axis layers, we can create chart content layers (i.e. the layers that render our data) with them. More specifically, with the ChartAxis
instances which are contained in ChartAxisLayer
. ChartAxis
contains the logic to map between domain and screen coordinates, which is what the content layers (i.e. subclasses of ChartCoordsSpaceLayer
) need. To instantiate a line layer, for example:
ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]...)
Finally, if we want the axis to be rendered, we have to pass the axis layer to the chart's layers array. This isn't required logic-wise. The chart doesn't know anything about axes, it just sees layers that have to be rendered.
To hide both axis lines, pass 0 as ChartSettings.axisStrokeWidth
to the chart. To hide only one of them, you can pass a transparent lineColor
parameter to ChartAxisModel
(The latter is admittedly a bit workaround-ish. I may add axisStrokeWidth
to the axis models too in the future).
From the above follows, that to hide an axis you simply have to not pass its layer to the chart's layers array. Depending on how we define "hiding", this may be not entirely true, as the space will still be reserved. The reason of this is that the inner frame, as well as the edges of other axes were already computed by ChartCoordsSpace
. The easiest way at the moment to not reserve space for an axis is to pass an axis model that's effectively empty and ensure that all the related spacing settings in ChartSettings
are 0. A detailed list of these steps can be found in this issue.
To work with multiple axes you have to use multiple layers and pass to each layer the x and y axis it should use. For example, while to display a chart with 3 lines using the same domain (i.e. a single x and y axis) you would use only one line layer, to add a second y axis and have one of the lines referencing it, you would create a new line layer, which would be initialized with this second y axis instead of the other and the line you want to use it.
So far it's cleared how to create content layers when we have the axes, but how to we create the axes? How do we transform our axis models into axis layers, that will be positioned where we want? This is where the more explicit way to use ChartCoordsSpace
comes into play, where we can pass arrays of axis models, instead of only one x and y axis:
ChartCoordsSpace(chartSettings: chartSettings, chartSize: viewFrame.size, yLowModels: yLowModels, yHighModels: yHighModels, xLowModels: xLowModels, xHighModels: xHighModels)
The mapping of the arrays and positions in these and the actual position of the axes on the screen can be viewed in the following image:
To retrieve the generated axis layers from the ChartCoordsSpace
's result (the returned object from above initializer), you use the same indices you used for the axis models, on the following arrays:
let yLowAxes = coordsSpace.yLowAxesLayers
let yHighAxes = coordsSpace.yHighAxesLayers
let xLowAxes = coordsSpace.xLowAxesLayers
let xHighAxes = coordsSpace.xHighAxesLayers
Now you have the axis layers, which contain the axes that you need to initialize the chart layers, in the same way as it's done when working with single axes.
Here's a full example.