How To Create a Rotating Wheel Control with UIKit

This is a post whew you will learn how to build custom Rotating Wheel Control with UIKit, written by iOS Tutorial Team member Cesare Rocchi, a UX designer and developer specializing in web and mobile applications. There may be times you’ve downloaded a cool application with a new kind of user interface component, and you’ve […] By Cesare Rocchi.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 6 of this article. Click here to view the first page.

Adding Rotation

If you were to put into words what you need done via code, it would sound pretty easy:

  • When the user taps, store the "current value" of radians.
  • Each time the user drags their finger, calculate the new radian value and set it as an affine transformation.
  • When the user lifts their finger, calculate the current sector and adjust the rotation to center the wheel.

But as they say, the devil is in the details.

To calculate the angle by which the wheel has to be rotated, you need to transform Cartesian coordinates to polar ones. What does that mean?

When you detect a tap on a component, you can find out its x and y Cartesian coordinates according to a "reference point," which is usually the upper-left corner of the component. In this scenario, you’re in a "circled" world, where the pole is the center of the container. For example, say the user taps on the point (30, 30) of the wheel as in the following image:

What is the angle between the tapped point and the x-axis of the center (blue line)? You need to know this value to calculate the angle "drawn" by the user when dragging their finger on the wheel. That will be the rotation value applied to the container.

I’ll save you some hair-pulling and struggling with equations. The way to calculate the angle for the above is using the arctangent function, the inverse of the tangent. And guess what? The function returns a radian value - exactly what you needed!.

But here comes another devilish little detail - the range of the arctangent function is from -pi to pi. If you remember, as mentioned above, your range is from 0 to 2pi. It's not something that cannot be handled, but do remember to take this into account in your future calculations. Otherwise, the on-screen display could end up looking weird.

Enough theory, let's see some code! In SMRotaryWheel.h, add a new property as follows:

@property CGAffineTransform startTransform;

This is needed to save the transform when the user taps on the component. To save the angle when the user touches the component, we add a static variable of type float to the top of SMRotaryWheel.m, just above the @implementation line, as follows:

static float deltaAngle;

You should also synthesize the startTransform property added previously as follows:

@synthesize startTransform;

Now, we have to detect user touches. When a user taps on a UIComponent instance, the touch is handled via the beginTrackingWithTouch:touch withEvent:event method. So let's override that method by adding the following code to the end of SMRotaryWheel.m just below rotate:

Note: In case you’ve chosen to extend UIView instead of UIControl, the method to override is touchesBegan:touches withEvent:event.

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
    // 1 - Get touch position
    CGPoint touchPoint = [touch locationInView:self];
    // 2 - Calculate distance from center
    float dx = touchPoint.x - container.center.x;
    float dy = touchPoint.y - container.center.y;
    // 3 - Calculate arctangent value
    deltaAngle = atan2(dy,dx); 
    // 4 - Save current transform
    startTransform = container.transform;
    return YES;
}

The first thing you do is to find the Cartesian coordinates of the touch point on the wheel. Then you calculate the different between the touch point and the center of the container. Finally, you get the arctangent value and save the current transform so that you have an initial reference point when the user starts dragging the wheel.

You return YES from the method because the wheel is also meant to respond when a touch is dragged, as you will see in a minute. Now that you've saved the angle when the interaction begins, the next step is to calculate the rotation angle according to how the user dragged their finger.

For example, let's suppose the user touched the component at (50,50) and dragged their finger to (260, 115).

You have to calculate the radians for the end point and subtract that value from the delta saved when the user first touched the component. The result is the number of radians to be passed to the affine transform. This has to be done for each drag event thrown by the component. The event to be overridden here is
continueTrackingWithTouch withEvent: - add it below beginTrackingWithTouch:

Note: If you subclass UIView, the method to override is touchesMoved:withEvent:.

- (BOOL)continueTrackingWithTouch:(UITouch*)touch withEvent:(UIEvent*)event
{
    CGPoint pt = [touch locationInView:self];
    float dx = pt.x  - container.center.x;
    float dy = pt.y  - container.center.y;
    float ang = atan2(dy,dx);
    float angleDifference = deltaAngle - ang;
    container.transform = CGAffineTransformRotate(startTransform, -angleDifference);
    return YES;
}

As you'll notice, the radian calculation is pretty similar to what we did in beginTrackingWithTouch. Notice as well that the code specifies -angleDifference to compensate for the fact that values might be in the negative quadrant.

Finally, don't forget to comment out section #4 of initWithFrame: so that the wheel doesn't automatically rotate any longer.

Now compile and run. See? You’re getting there! You have the first working prototype of a rotating wheel, which is working pretty good!

There are a few oddities though. For example, if the user taps a point very close to the center of the wheel, the application still works, but the rotation might be "jumpy." This is due to the fact that the angle drawn is "mashed," as in the following picture.

Things get even more jumpy if the path drawn by the finger crosses the center, as follows.

You can verify such a behavior in the current implementation. While the code still works and the results are right, the interaction experience can be improved.

To solve this issue, resort to the same solution used in hardware wheels, like the good old rotary dial, which makes it difficult to use the wheel from too close to the center. Your goal is to ignore taps too close to the center of the wheel, by preventing the dispatch of any event when such taps occur.

To achieve this result, there is no need to calculate the arctangent – Pythagoras' theorem is enough. But you need a helper function, calculateDistanceFromCenter, for which you add the definition at the top of SMRotaryWheel.m, right after the drawWheel definition.

@interface SMRotaryWheel()
    ...
    - (float) calculateDistanceFromCenter:(CGPoint)point;

Then, add the implementation after continueTrackingWithTouch:

- (float) calculateDistanceFromCenter:(CGPoint)point {
    CGPoint center = CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2);
    float dx = point.x - center.x;
    float dy = point.y - center.y;
    return sqrt(dx*dx + dy*dy);
}

This just measures how far the tap point is from the center. Now add the following right after section #1 in beginTrackingWithTouch:withEvent::

    // 1.1 - Get the distance from the center
    float dist = [self calculateDistanceFromCenter:touchPoint];
    // 1.2 - Filter out touches too close to the center
    if (dist < 40 || dist > 100) 
    {
        // forcing a tap to be on the ferrule
        NSLog(@"ignoring tap (%f,%f)", touchPoint.x, touchPoint.y);
        return NO;
    }
}

This way, when taps are too close to the center, the touches are simply ignored because you return a NO, indicating that the component is not handling that touch.

Note: If you have chosen to extend UIView, you have to implement this behavior in touchesMoved:withEvent.

You might tune the two values in the first line of section #1.2 (40 and 100) according to the dimensions of your wheel to define the allowed tap area, similar to what's shown in the following image (in blue):

You might want to perform the same check also in continueTrackingWithTouch:withEvent. Check out the project source code for more information.

Now comes the hard part. What you’re going to implement in the next section is a "come-to-rest" effect. That is, when the user lifts their finger from the screen, the wheel will "center" on the midpoint of the current sector.

Contributors

Over 300 content creators. Join our team.