Building a range selector with ShinobiCharts: Part II - Creating custom handle annotations
This tutorial is also available on the ShinobiControls blog. You’ll find better support and assistance on this site as part of ShinobiDeveloper
In part one, we built a simple app which drew 2 charts from the same data source, one of which allows user interaction, the other of which has an overlay which displays which region of the entire data set the main chart is currently displaying. If you haven’t read/skimmed it I reckon this post will make a lot more sense if you do that first. It’s pretty long, but is pretty approachable.
The first post laid a lot of the ground work upon which we can build cool things. We’ve got some data and some charts, and a range selector annotation. In this post we’re going to allow users to interact with the range annotation - so that dragging it will update the range displayed within the main chart. Cool, let’s get on it.
Interacting with the range selector
We have already created a class to manage the range annotation, and most of the
work we’re going to be doing will be within that class. We want to be able to get
a user to interact with annotation, and usually in iOS we’d use gesture recognisers.
Helpfully, SChartAnnotation
is a subclass of UIView
, so all the gesture
magic we get from iOS we can use ourselves.
Our first interaction will be the ability for a user to drag the range annotation along the range of the x-axis - using a pan gesture. The concept is simple - we will add an invisible annotation to represent the selected range, and add a gesture recogniser - however, there are a few nuances which we will have to address as we go.
Let’s get started with creating the invisible annotation:
We’ve added a new ivar to keep hold of our new annotation, and then we’ve created the annotation itself. We also update the limits of the annotation in our API method which is called when the user interacts with the main chart.
You might however have noticed that we aren’t creating this annotation in the
same way we have created the lines and the shaded regions. We have instead created
our own SChartAnnotation
subclass instead, and this is due to the way in which
annotations work internally.
When an annotation is created, it is generally created with a size of 1x1. Then,
when the annotations size is required to change, it has its transform
property
updated with an appropriate affine transform to get it to display as required.
This works great for general annotations, but it has the side-effect that a pan
gesture recogniser won’t get triggered within it - the most you can drag is 1pt
- not enough to be recognised as a pan. Therefore we create our own subclass of
SChartAnnotationZooming
which will change this behaviour:
The constructor has the arguments we need to set up the SChartAnnotationZooming
superclass, which is as expected:
Then we need to override 2 UIView
methods. setTransform:
is called by the
ShinobiCharts framework when the x and y limit values associated with the
annotation are changed, or when the chart is zoomed. Only the former of these
situations applies to us here, and since we are displaying a completely transparent
view (remember it is only there as a gesture target) we prevent the transform from
being set, and instead change the frame size. This is actually really simple, and
doesn’t require any clever calculation. If we were displaying something in our
annotation, and allowing chart zooming, then we would have to think about this in
more detail, but the following is fine for now:
The other method we override is layoutSubviews
, and we do this to ensure that
the height of our annotation is the same as the y-axis. We do this in this method
to ensure that if our chart changes size then the annotation will scale vertically
as appropriate:
Adding gestures
Now that we’ve got our invisible annotation as a touch target, we can add the a pan gesture:
We add a new method to the annotation manager which will create the gesture
recognisers and add them to the annotation. The last 2 lines of this
prepareGestureRecognisers
method does just that - standard UIGestureRecognizer
usage. However, before that there is some more ShinobiChart internal magic.
In order that gesture recognisers work, their entire parent view hierarchy has to
have userInteractionEnabled = YES;
. Because of where annotations appear within
the view hierarchy, this isn’t true by default. Therefore the first part of the
prepareGestureRecognisers
method ensures that this property is set correctly
for our annotation to be able to receive gestures. Annotations appear on the
glView
subview of the canvas
of a chart by default, so we make sure that all
of these behave as expected. Note, in order to get access to subviews of the canvas
layer you’ll have to import the SChartCanvas
header:
When we created the gesture, we said that the handler would be called handlePan:
,
so we should implement that:
The gesture callback itself is pretty simple because we’ve farmed out the mildly-
complicated calculation to a helper method. We’ll discuss that in a sec, but first
just run through the handlePan:
method. Firstly we use standard UIGestureReconizer
properties to find the pixel location of the touch in the chart’s canvas. Then,
using our helper method we find out what range this touch point should represent,
before calling our previously created method to update the range selector’s location.
We use the rangeCentredOnPixelValue:
method to find where we should move the
range selector to, given that it should be centred on a particular pixel (well,
pixel x-value). Most of this is self-explanatory, apart from the line which defines
and new variable called newCentreValue
. This is another little bit of Shinobi
magic, so I’ll explain this in a little more detail.
In order to convert a touch point in pixels on a chart to an underlying data point
we add a category on SChartAxis
which provides a new method to perform this
calculation:
The implementation of this method is as follows:
We find the current displayed range of the axis, and the size in pixels of the chart. Provided that the axis isn’t logarithmic or discontinuous, then there is a linear relationship between the data range and the width in pixels. We simply calculate the data value using this linear relationship:
Linking back to the main chart
So now, if you fire up your app, you should have a range selector which you can drag across the range chart - pretty neat eh? But as you drag it, the main chart isn’t updated as we would expect. So let’s fix that.
We’ll create a new delegate protocol which the annotation manager will use to inform interested parties that the range annotation has moved:
We’ll make our ShinobiRangeSelector
adopt this protocol:
Really nice and simple - when the method is called, we update the range on the x-axis of the main chart to show the specified range, and redraw the chart.
Now we need to ensure that the delegate method is called appropriately. We add
a delegate
property to the annotation manager:
And then add a utility method to the implementation to call the delegate method. This isn’t vital at this stage as we’re only going to call the delegate method in one place, however, in later parts of this project we’ll be adding other calls:
We call this utility method as part of handlePan:
, i.e. when the pan gesture we
created before is fired:
The last thing to do (actually, the thing I always forget to do when creating delegates) is to make sure that the have assigned the delegate property on the annotation manager when we create it:
Since we’ve already implemented the required delegate methods, when we fire the app up now, we should have the behaviour we wanted - when you drag the range selector, the main chart scrolls with you. That’s really cool, but we’re not done yet…
Adding handles to the range selector
Although we can pan the range selector, ideally we would be able to change the length of the selection as well. To do this we’ll add some handle annotations to the ends of the range selector, and add gesture recognisers to them, so that the user can grab hold of one of them and drag it to change the end point of the range.
We’ll create the handle as a custom SChartAnnotation
subclass:
In a similar way to the previous custom annotation we made we create a constructor which then sets all the required properties:
The only interesting part of this is that setting the yValue
property to nil
will ensure that the handle will be drawn vertically centred - which is perfect
for our purposes. We have defined a custom method drawHandleWithColour:
which
actually creates the handle:
This is all standard UIView
code. In order to set the corners to be rounded
you need to have imported QuartzCore.h
:
We do some trivial calculations to draw 3 evenly spaced vertical lines within the
handle view to give a more traditional gripper appearance. We draw lines as 1pt
wide UIView
s.
Now we just need to add these handles to the range selector in the annotation manager. We add some ivars to keep hold of them:
And then we can create instances of them and add them to the chart:
And then, in the same way we added a gesture recogniser to the selected region annotation, we can add one to each of the grippers:
We you can see we’ve added a new method to handle the dragging of the handles:
This method does much the same as the handlePan:
method we created for when
dragging the entire selector:
- We find the current touch point and perform the same magic as before to establish what data value this pixel value represents
- Then we decide which of our grippers we have moved, and from this create the
updated
SChartRange
we should be displaying. - A call to the
moveRangeSelectorToRange:
method will ensure we move all the annotations to the updated location. In this instance we’re actually asking it to resize the selected region and one of the shaded regions, and to move one of the lines and the grippers. - Finally we need to make a call to the delegate method so that the main chart can update itself as well.
Because of the way we built up the manager, this process is actually pretty simple - we’ve got methods to move the selector and inform the delegate.
Moving on
We’ve now got a range selector which can update the main chart both by dragging it, and by dragging the custom gripper annotations we’ve created at the end. This is starting to get pretty good, but there are still some things we want to work on. Firstly, it’s currently possible to drag the upper range selector gripper to below the lower. This causes the main chart to start complaining that it can’t display a range where the maximum is lower than the minimum. We’ll fix this bug in the next post.
The other thing we’ll look at is adding momentum to the selector drag motion - this is something we come to expect from iOS controls, and you can see that it works really well when you interact with the main chart. Currently however, when you let go of the range selector it stops dead, so we will build a momentum animation to improve the user experience.
You can read part III here.
Update 2013/03/10
Edited the post to remove the use of internal methods from the ShinobiCharts framework. This matches the updates to the codebase in the repository.