Blog

Back to Blog

Customizing your carousel path

Posted on 15 Jul 2013 Written by Alison Clarke

One of the fantastic new features of ShinobiEssentials 2.0 is the carousel. Out of the box, you can easily create linear carousels, cylindrical carousels, cover flows and more…but what if you want something a little bit different? Well that’s possible too of course – with just a little work.

This tutorial takes you through the process of creating a custom carousel. The example used mimics a conveyor belt, and attempts to reproduce the effect of the iOS7 Safari tabs screen. If you get stuck along the way, you can see the finished project on GitHub, or download it as a zip.

Screenshot

To get started, you’ll need a copy of Xcode and also (of course) a copy of ShinobiEssentials (you can download a trial here). If you haven’t used the ShinobiEssentials carousel before, it might be a good idea to read through the Quick Start Guide before continuing.

Setting things up

First, create a new Single View Application in Xcode. We’ll turn on ARC to make things simpler, but we don’t need Storyboards or Unit testing, and we’ll set the application up for iPad.

Your project will open on the summary page. Go to the “Linked Frameworks and Libraries” section and add QuartzCore.framework and Security.framework. You also need to drag-drop in ShinobiEssentials.embeddedFramework, to use both the framework and bundled Resources. To check you did this correctly, open up the project window, go to the Build Phases tab and check that ShinobiEssentials.framework is under “Link Binary With Libraries”, and that under “Copy Bundle Resources”, there are lots of files beginning with “essentials_*.png” (such as “essentials_donebutton.png”).

If you’re using the trial version you’ll need to add your license key. Open up AppDelegate.m, import <ShinobiEssentials/SEssentials.h>, and set the license key inside application:didFinishLaunchingWithOptions: as follows:

#import <ShinobiEssentials/SEssentials.h>
 
@implementation AppDelegate
 
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [SEssentials setLicenseKey:@"your license key"];
    …
}

Creating a carousel

Before we get into the nitty-gritty of creating a custom carousel path, let’s set up our carousel using one of the built-in types. In our demo app the items in the carousel will be UIViews with a colored background, a white border, and a gradient shadow. We’ll store them in an array inside the ViewController, which will be the carousel’s data source.

The first change to make is to open up ViewController.xib in Interface Builder, and change the background color of its view to black.

Next, open up ViewController.h, import ShinobiEssentials and declare that the class will implement the SEssentialsCarouselDataSource protocol:

#import <UIKit/UIKit.h>
#import <ShinobiEssentials/ShinobiEssentials.h>

@interface ViewController : UIViewController<SEssentialsCarouselDataSource>

@end

Now open up ViewController.m and add a couple of private variables at the top, to store the number of items in the carousel and the carousel views:

@implementation ViewController {
    int _numberOfItemsInCarousel;
    NSMutableArray *_carouselData;
}

And then add a method to create our views:

- (void)setupCarouselData
{
    // Create an array of coloured, bordered views to go in the carousel
    _carouselData = [[NSMutableArray alloc] init];
    
    for (int i=0; i<=_numberOfItemsInCarousel; i++) {
        // Create a view
        UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width * 0.8, 300)];
        
        // Add a subview, colored depending on its position in the carousel, with a border
        UIView *borderedView = [[UIView alloc] initWithFrame:view.bounds];
        borderedView.backgroundColor = [UIColor colorWithHue:((float)i)/_numberOfItemsInCarousel saturation:1.0 brightness:0.5 alpha:1.0];
        borderedView.layer.borderWidth = 2.f;
        borderedView.layer.borderColor = [UIColor whiteColor].CGColor;
        borderedView.layer.shouldRasterize = YES;
        [view addSubview:borderedView];
        
        // Apply a shadow to the outer view, so it applies to the border as well as the contents
        CAGradientLayer *shadowGradient = [CAGradientLayer layer];
        shadowGradient.startPoint = CGPointMake(0.5, 0);
        shadowGradient.endPoint = CGPointMake(0.5, 1);
        shadowGradient.locations = @[@0, @1];
        shadowGradient.colors = @[(id)[UIColor colorWithWhite:0 alpha:0].CGColor,
                                  (id)[UIColor colorWithWhite:0 alpha:0.9f].CGColor];
        shadowGradient.frame = view.bounds;
        [view.layer addSublayer:shadowGradient];
        
        // Add the view to the carousel data
        [_carouselData addObject:view];
    }
}

Hopefully the comments make this method reasonably clear: colors, gradients and borders aren’t the main point of this tutorial so I’m not going to go into any detail about them here.

Our carousel needs an SEssentialsDataSource to provide its data, so we need to implement the SEssentialsCarouselDataSource protocol. There are two methods needed: numberOfItemsInCarousel: (hopefully self-explanatory) and carousel:itemAtIndex: which returns the UIView to be displayed at the given index of the carousel. As we’ve already got an array of UIViews, our implementation of these methods in ViewController.m is trivial:

#pragma mark - SEssentialsCarouselDataSource methods

-(int)numberOfItemsInCarousel:(SEssentialsCarousel *)carousel
{
    return _numberOfItemsInCarousel;
}

-(UIView *)carousel:(SEssentialsCarousel *)carousel itemAtIndex:(int)index
{
    return _carouselData[index];
}

Now, to get a carousel up and running, inside viewDidLoad we need to define how many items we want, call our setupCarouselData method, create and set up a carousel (we’ll use a vertical linear 2D one for now), and add it to our view:

- (void)viewDidLoad
{
    [super viewDidLoad];
    
	// Set up the carousel data
    _numberOfItemsInCarousel = 10;
    [self setupCarouselData];
    
    // Create a carousel
    SEssentialsCarouselLinear2D *carousel = [[SEssentialsCarouselLinear2D alloc] initWithFrame:self.view.bounds];
    carousel.orientation = SEssentialsCarouselOrientationVertical;
    carousel.dataSource = self;
    
    [self.view addSubview:carousel];
}

If you run the app now, you’ll see a carousel which lets you scroll through the views from bottom to top:

01 2D Carousel

Customizing your carousel path

To create a custom carousel path, we need to subclass SEssentialsCarousel. There are two methods you can override to set up your custom path: positionOfItemAtOffset:, which allows you to move the carousel items in two dimensions, and transformOfItemAtOffset:, which allows you to do more complex transformations. There’s an example using positionOfItemAtOffset: in the how-to guides in the ShinobiEssentials API docs, but today we’re going to concentrate on using transformOfItemAtOffset:. (It is possible to combine the two, in which case positionOfItemAtOffset: is called first, but for our purposes the code is simpler if we do everything in one method.)

To start creating your custom carousel, create a new Objective-C class called ConveyorBeltCarousel, a subclass of SEssentialsCarousel. Add some properties to ConveyorBeltCarousel.h:

/** The maximum number of items to fit on the front of the conveyor belt (defaults to 5)*/
@property (nonatomic, assign) int maxNumberOfItemsOnFront;

/** The radius of the rollers on the end of the conveyor belt (defaults to 150)*/
@property (nonatomic, assign) float rollerRadius;

/** The rotation applied to the off-center items (defaults to 0.1) */
@property (nonatomic, assign) float rotateFactor;

And initialize them in initWithFrame: in ConveyorBeltCarousel.m:

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        // Set up some default values
        self.panVector = CGPointMake(0, 1);
        self.maxNumberOfItemsOnFront = 5;
        self.rollerRadius = 150.f;
        self.rotateFactor = 0.1f;
    }
    return self;
}

The panVector line in the initializer makes the carousel scroll in response to vertical swipes.

We’ll start with a fairly straightforward implementation of transformOfItemAtOffset: which just spaces the items out vertically (like the 2D linear carousel we just saw) and rotates the items away from us. The offsetFromFocusPoint provided to this method is the distance in indices between the item and the focus point of the carousel. So in the initial position of the carousel, the 0th item will have offset 0, the 1st item will have offset 1, etc; as the items scroll up, these offsets will increase. In our initial implementation we just work out how far apart the items should be (based on the height of the carousel and maxNumberOfItemsOnFront), then scale up the offset by this spacing. We then create a CATransform3D and use it to translate the item up the y-axis by the calculated offset.

The next effect we apply is to modify the CATransform3D.m24 and CATransform3D.m34 properties. This has the effect of changing the camera angle so we get a better view of the rotation.

The final transformation we apply (for now) is a rotation away from us around the x-axis, based on rotateFactor.

So our initial attempt at a transformOfItemAtOffset: method looks like this:

- (struct CATransform3D)transformOfItemAtOffset:(float)offsetFromFocusPoint
{
    // Work out how far apart the items should be
    float itemSpacing = self.bounds.size.height/self.maxNumberOfItemsOnFront;
    
    // Work out the yOffset
    float yOffset = itemSpacing * offsetFromFocusPoint;
    
    // Create the transform and translation
    CATransform3D transform = CATransform3DIdentity;
    transform = CATransform3DTranslate(transform, 0, yOffset, 0);
    
    // Next we change the "camera angle" and rotate the items downwards
    transform.m24 = 1/2000.f;
    transform.m34 = -1/500.f;
    transform = CATransform3DRotate(transform, -M_PI_2 * self.rotateFactor, 1, 0, 0);
    
    return transform;
}

If you run the app now, you should get something like this:

02 Initial Transform

It’s a bit like the 2D linear carousel, but the items are now rotated. That’s pretty cool (especially once you’ve got something interesting on your views), but it’s not much like a conveyor belt (or those iOS7 Safari tabs). So let’s do a bit more work on our transformation…

Making a conveyor belt

Before we get into the details of the transformations for the conveyor belt, let’s take a look at what we’re attempting to do. Here’s a view of the conveyor belt from the side, with some distances marked which will help us in our calculations:

03 Conveyor Belt

rollerRadius is already a property of the ConveyorBeltCarousel, but we need to work out itemSpacing and rollerBoundary. We’ll store these as private variables in ConveyorBeltCarousel.m:

@implementation ConveyorBeltCarousel {
    // _rollerBoundary is the distance from the middle of the conveyor belt to the middle of the roller,
    // and is calculated so that the conveyor belt fits exactly in the view
    float _rollerBoundary;
    // _itemSpacing is the distance between the items on the carousel
    float _itemSpacing;
}

We’ll fit the conveyor belt exactly in our frame, so we need _rollerBoundary plus rollerRadius to be half of the frame height. Then, we calculate _itemSpacing to fit maxNumberOfItemsOnFront items on the “front” of the conveyor belt (the flat bit between the rollers), i.e. on 2 * _rollerBoundary. We’ll add a method to ConveyorBeltCarousel.m to calculate these values:

- (void)calculateBoundaryAndSpacing
{
    // Calculate the roller boundary based on the view height and rollerRadius
    _rollerBoundary = self.bounds.size.height/2 - self.rollerRadius;
    // Calculate the item spacing so that maxNumberOfItemsOnFront will fit onto the "flat" part
    // of the conveyor belt
    _itemSpacing = _rollerBoundary * 2/self.maxNumberOfItemsOnFront;
}

We’ll call the method in initWithFrame:, after setting up the properties. We’ll also add observers so we can recalculate the _rollerBoundary and _itemSpacing whenever the properties rollerRadius or maxNumberOfItemsOnFront change:

        // Calculate the roller boundary and the item spacing
        [self calculateBoundaryAndSpacing];
        
        // Add observers so we can update the boundary and spacing whenever the rollerRadius or
        // maxNumberOfItemsOnFront are changed
        [self addObserver:self forKeyPath:@"rollerRadius" options:NSKeyValueObservingOptionNew context:nil];
        [self addObserver:self forKeyPath:@"maxNumberOfItemsOnFront" options:NSKeyValueObservingOptionNew context:nil];

We then add an implementation of observeValueForKeyPath:ofObject:change:context: to respond to the observers, and make sure we remove the observers in dealloc:

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ([keyPath isEqual:@"rollerRadius"] || [keyPath isEqual:@"maxNumberOfItemsOnFront"]) {
        // If our rollerRadius or maxNumberOfItemsOnFront properties have changed, we need to
        // recalculate the rollerBoundary and itemSpacing, and redraw the carousel.
        [self calculateBoundaryAndSpacing];
        [self redrawCarousel];
    } else {
        // Otherwise we pass the observations up to the parent
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

-(void)dealloc {
    // Remove the observers
    [self removeObserver:self forKeyPath:@"rollerRadius"];
    [self removeObserver:self forKeyPath:@"maxNumberOfItemsOnFront"];
}

So we’ve got our variables set up; now we need to modify our transformOfItemAtOffset: method to provide a more appropriate transformation. We’ll work out a scaled offset based on _itemSpacing as we did before: this is the distance around the conveyor belt, from the middle of the front. We’ll use the absolute value for now to make the math easier:

float scaledOffset = _itemSpacing * fabsf(offsetFromFocusPoint);

(I’ll show you the finished function shortly, so don’t worry about copying all of these code snippets.)

From this basic value we need to calculate appropriate offsets on the y and z axes. We’ve got three cases to consider:

  1. scaledOffset is smaller than rollerBoundary.
  2. scaledOffset is bigger than rollerBoundary by an amount less than half the circumference of the roller.
  3. scaledOffset is bigger than rollerBoundary plus half the circumference of the roller.

Case 1 is the simplest case, where the item is on the front of the conveyor belt, and the yOffset is simply scaledOffset (made negative again where necessary), and the zOffset is 0.

In case 2, the item is on the end of the roller, so we need to do a bit of trigonometry to calculate our y and z offsets. I’m going to go back to basics here – read on to case 3 if these calculations are obvious to you! Here’s the top of the conveyor belt again, with some new properties marked:

04 Maths

Here, r is the rollerRadius, and s is the difference between scaledOffset and _rollerBoundary, and from these we need to calculate θ (our angle), o (opposite) and a (adjacent), to work out our y and z offsets.

We’ll do our workings in radians (2π radians = 360°), so θ is simply s divided by the radius:

float angle = (scaledOffset - _rollerBoundary)/self.rollerRadius;

Next, you might remember from school that o = r sinθ, and our total y offset is _rollerBoundary plus o, calculated as follows:

yOffset = _rollerBoundary + self.rollerRadius * sin(angle);

Similarly, a = r cosθ, and our z offset is the difference between rollerRadius and a: as it’s going away from us we need a negative z offset, so we calculate it as follows:

zOffset = self.rollerRadius * (cos(angle) - 1);

(Because of the shape of the sine and cosine functions, these calculations give us the right answers for obtuse angles as well as acute ones, but I won’t go into the details of that here.)

Case 3 is where the item is on the back of the conveyor belt. The scaledOffset goes round the roller and back down the other side, so the y offset is half the length of the conveyor belt (i.e. 2*_rollerBoundary + π*rollerRadius) minus scaledOffset:

yOffset = 2 * _rollerBoundary + M_PI * self.rollerRadius - scaledOffset;

And the z offset is twice the roller radius, negative because it’s away from us:

zOffset = -2 * self.rollerRadius;

(We’re not going to cover the case where _itemSpacing is more than half the length of the carousel, as there’s not really anything sensible we can do with the transformation. The calculations in case 3 will apply in these cases, so the items will continue to travel up or down at the back of the carousel. If you’ve got more items than will fit on the conveyor belt, you can adjust the carousel’s maxNumberOfItemsToDisplay property, to hide the items with too great an offset.)

The final bits of work we need to do are to make the y offset negative if the original offset was negative, and to apply the y and z translations, as well as the rotation we used previously. We need to apply the y translation first, then the rotation, and finally the z translation, to achieve the effect we want. The final function looks like this:

- (struct CATransform3D)transformOfItemAtOffset:(float)offsetFromFocusPoint
{
    CATransform3D transform = CATransform3DIdentity;
    
    // Work out how far from the front middle of the conveyor belt the item should be
    float scaledOffset = _itemSpacing * fabsf(offsetFromFocusPoint); 
    
    // The y offset is based on the scaledOffset, but if that's near or past the end of the conveyor
    // belt, it needs to be adjusted so it looks like it's rolling round the end
    float yOffset, zOffset;
    
    if (scaledOffset < _rollerBoundary) {
        // The item is on the "front" of the conveyor belt, so the yOffset is just scaledOffset,
        // and the zOffset is 0
        yOffset = scaledOffset;
        zOffset = 0;
    } else if (scaledOffset < _rollerBoundary + M_PI * self.rollerRadius) {
        // The item is on the roller of the conveyor belt, because its offset is less than the
        // rollerBoundary plus half the circumference of the roller.
        // Work out the angle from the middle of the roller to the item: because we're working
        // in radians, it's just the distance around the circumference divided by the radius
        float angle = (scaledOffset - _rollerBoundary)/self.rollerRadius;
        
        // The y distance above the roller boundary is r * sin(angle)
        yOffset = _rollerBoundary + self.rollerRadius * sin(angle);
        
        // The z distance away from us is r - r * cos(angle); we need the negative of that to move
        // the item away from rather than towards us
        zOffset = self.rollerRadius * (cos(angle) - 1);
    } else {
        // The item is on the "back" of the conveyor belt because its offset is greater
        // than the rollerBoundary plus half the circumference of the roller.
        // The yOffset we need is the half the length of the conveyor belt, minus scaledOffset.
        // The length of the belt is 4 * rollerBoundary + 2 * pi * rollerRadius
        yOffset = 2 * _rollerBoundary + M_PI * self.rollerRadius - scaledOffset;
        
        // The zOffset is just the diameter of the rollers, negative because it's away from us
        zOffset = -2 * self.rollerRadius;        
    } 
    
    // Sort out negative offsets so they're still negative
    if (offsetFromFocusPoint < 0) {
        yOffset = -yOffset;
    }
    
    // We apply the y translation first
    transform = CATransform3DTranslate(transform, 0, yOffset, 0);
    
    // Next we change the "camera angle" and rotate the items downwards
    transform.m24 = 1/2000.f;
    transform.m34 = -1/500.f;
    transform = CATransform3DRotate(transform, -M_PI_2 * self.rotateFactor, 1, 0, 0);
    
    // Finally we apply the z translation
    transform = CATransform3DTranslate(transform, 0, 0, zOffset);
    return transform;
}

If you run the carousel now, you’ll start to see the conveyor belt effect, but it’s not quite right yet. It would work OK for smaller items, but the “camera angle” means that we can’t see the top of the carousel, and because we’re concentrating on the position of the top of the items, there’s a slightly odd effect at the bottom end of the carousel. We could probably do some more complicated things with the transformation to make this look better, but as the effect we’re really after is something like those iOS7 Safari tabs, we don’t really need to worry about what happens at the bottom: instead we’ll move the carousel so we can just see the top of it. We’ll also tweak its momentum, and add a nice panning effect when it first loads. We’ll do all this back in viewDidLoad in ViewController.m:

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // Set up the carousel data
    _numberOfItemsInCarousel = 10;
    [self setupCarouselData];
    
    // Create a conveyor belt carousel
    ConveyorBeltCarousel *carousel = [[ConveyorBeltCarousel alloc] initWithFrame:self.view.bounds];
    carousel.dataSource = self;
    
    // Adjust the focus point so we don't see the bottom part of the conveyor belt
    carousel.focusPointNormalized = CGPointMake(0.5, 0.7);
    // Make the momentum last a bit longer
    carousel.frictionCoefficient = 0.8;
    
    // Add the view
    [self.view addSubview:carousel];
    
    // Pan up to the 4th item
    [carousel panToItemAtIndex:3 animated:YES withDuration:0.5];
}

If you run the app now, you’ll see a lovely effect as the items roll over the top of the conveyor belt. Maybe it’s not quite as smooth as those Safari tabs, but it’s a good start! If you’d like to improve on it (or if you got a bit stuck following the tutorial), the finished project is on GitHub. We’d love to see what you can do with custom carousel paths – please get in touch to tell us about it!

Back to Blog