3D Carousel effect on the iPad

Vin picture Vin · Mar 9, 2011 · Viewed 16.1k times · Source

I am trying to implement a 3D Carousel on the iPad, consisting of UIViews, an effect like what is shown over here.

I have gone through many similar questions on SO, but didn't find any staisfactory answers or no answers at all.

I am trying to achieve the effect through modifying the coverflow animation but it just doesn't give that slick effect.

Has anyone implemented this?(open for suggestions through quartz and openGL both)

Answer

Tommy picture Tommy · Mar 10, 2011

No need to dive into Quartz or OpenGL, assuming you don't mind foregoing the blur. The page you link to gets the perspective wrong (that's why the images in the background appear to move more quickly than those in the foreground), so the maths can be a little bit of smoke and mirrors.

There's full example code at the bottom. What I've done is used sine and cosine to move some views about. The basic theory behind it is that the point at angle a on the outside of a circle of radius r positioned on the origin is at (a*sin(r), a*cos(r)). That's a simple polar to Cartesian conversion, and should be clear from the trigonometry most countries teach to their teens; consider a right angled triangle with a hypotenuse of length a — what lengths are the other two sides?

What you can then do is reduce the radius of the y part to convert the circle into an ellipse. And an ellipse looks a bit like a circle that you're looking at from an angle. That ignores the possibility of perspective, but go with it.

I then fake perspective by making size proportional to the y coordinate. And I'm adjusting alpha in a way like the site you link to does blur, in the hope that's good enough for your application.

I effect position and scale by adjusting the affine transform of the UIViews I want to manipulate. I set the alpha directly on the UIView. I also adjust the zPosition on the view's layers (which is why QuartzCore is imported). The zPosition is like the CSS z position; it doesn't affect scale, only drawing order. So by setting it equal to the scale I've computed, it just says "draw larger things on top of smaller things", giving us the correct draw order.

Finger tracking is done by following one UITouch at a time through the touchesBegan/touchesMoved/touchesEnded cycle. If no finger is being tracked and some touches begin, one of them becomes the finger being tracked. If it moves then the carousel is rotated.

To create inertia, I have a little method that attaches to a timer keeps track of the current angle versus the angle one tick before. That difference is used like a velocity and simultaneously scaled downward to produce inertia.

The timer is started on finger up, since that's when the carousel should start spinning of its own volition. It is stopped either if the carousel comes to a standstill or a new finger is placed down.

Leaving you to fill in the blanks, my code is:

#import <QuartzCore/QuartzCore.h>

@implementation testCarouselViewController

- (void)setCarouselAngle:(float)angle
{
    // we want to step around the outside of a circle in
    // linear steps; work out the distance from one step
    // to the next
    float angleToAdd = 360.0f / [carouselViews count];

    // apply positions to all carousel views
    for(UIView *view in carouselViews)
    {
        float angleInRadians = angle * M_PI / 180.0f;

        // get a location based on the angle
        float xPosition = (self.view.bounds.size.width * 0.5f) + 100.0f * sinf(angleInRadians);
        float yPosition = (self.view.bounds.size.height * 0.5f) + 30.0f * cosf(angleInRadians);

        // get a scale too; effectively we have:
        //
        //  0.75f   the minimum scale
        //  0.25f   the amount by which the scale varies over half a circle
        //
        // so this will give scales between 0.75 and 1.25. Adjust to suit!
        float scale = 0.75f + 0.25f * (cosf(angleInRadians) + 1.0);

        // apply location and scale
        view.transform = CGAffineTransformScale(CGAffineTransformMakeTranslation(xPosition, yPosition), scale, scale);

        // tweak alpha using the same system as applied for scale, this time
        // with 0.3 the minimum and a semicircle range of 0.5
        view.alpha = 0.3f + 0.5f * (cosf(angleInRadians) + 1.0);

        // setting the z position on the layer has the effect of setting the
        // draw order, without having to reorder our list of subviews
        view.layer.zPosition = scale;

        // work out what the next angle is going to be
        angle += angleToAdd;
    }
}

- (void)animateAngle
{
    // work out the difference between the current angle and
    // the last one, and add that again but made a bit smaller.
    // This gives us inertial scrolling.
    float angleNow = currentAngle;
    currentAngle += (currentAngle - lastAngle) * 0.97f;
    lastAngle = angleNow;

    // push the new angle into the carousel
    [self setCarouselAngle:currentAngle];

    // if the last angle and the current one are now
    // really similar then cancel the animation timer
    if(fabsf(lastAngle - currentAngle) < 0.001)
    {
        [animationTimer invalidate];
        animationTimer = nil;
    }
}

// Implement viewDidLoad to do additional setup after loading the view, typically from a nib.
- (void)viewDidLoad 
{
    [super viewDidLoad];

    // create views that are an 80x80 rect, centred on (0, 0)
    CGRect frameForViews = CGRectMake(-40, -40, 80, 80);

    // create six views, each with a different colour. 
    carouselViews = [[NSMutableArray alloc] initWithCapacity:6];
    int c = 6;
    while(c--)
    {
        UIView *view = [[UIView alloc] initWithFrame:frameForViews];

        // We don't really care what the colours are as long as they're different,
        // so just do anything
        view.backgroundColor = [UIColor colorWithRed:(c&4) ? 1.0 : 0.0 green:(c&2) ? 1.0 : 0.0 blue:(c&1) ? 1.0 : 0.0 alpha:1.0];

        // make the view visible, also add it to our array of carousel views
        [carouselViews addObject:view];
        [self.view addSubview:view];
    }

    currentAngle = lastAngle = 0.0f;
    [self setCarouselAngle:currentAngle];

    /*
        Note: I've omitted viewDidUnload for brevity; remember to implement one and
        clean up after all the objects created here
    */
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    // if we're not already tracking a touch then...
    if(!trackingTouch)
    {
        // ... track any of the new touches, we don't care which ...
        trackingTouch = [touches anyObject];

        // ... and cancel any animation that may be ongoing
        [animationTimer invalidate];
        animationTimer = nil;
        lastAngle = currentAngle;
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    // if our touch moved then...
    if([touches containsObject:trackingTouch])
    {
        // use the movement of the touch to decide
        // how much to rotate the carousel
        CGPoint locationNow = [trackingTouch locationInView:self.view];
        CGPoint locationThen = [trackingTouch previousLocationInView:self.view];

        lastAngle = currentAngle;
        currentAngle += (locationNow.x - locationThen.x) * 180.0f / self.view.bounds.size.width;
        // the 180.0f / self.view.bounds.size.width just says "let a full width of my view
        // be a 180 degree rotation"

        // and update the view positions
        [self setCarouselAngle:currentAngle];
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // if our touch ended then...
    if([touches containsObject:trackingTouch])
    {
        // make sure we're no longer tracking it
        trackingTouch = nil;

        // and kick off the inertial animation
        animationTimer = [NSTimer scheduledTimerWithTimeInterval:0.02 target:self selector:@selector(animateAngle) userInfo:nil repeats:YES];
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    // treat cancelled touches exactly like ones that end naturally
    [self touchesEnded:touches withEvent:event];
}

@end

So relevant member variables are a mutable array, 'carouselViews', a timer, 'animationTimer', two floats, 'currentAngle' and 'lastAngle', and a UITouch, 'trackingTouch'. Obviously you'd probably want to use views that aren't just coloured squares and you might want to adjust the numbers I've pulled out of thin air for positioning. Otherwise, it should just work.

EDIT: I should say, I wrote and tested this code using the iPhone 'View-based application' template in Xcode. Create that template, dump my stuff into the created view controller and add the necessary member variables to test. However, I've realised that the touch tracking assumes 180 degrees is the full width of your view, but the setCarouselAngle: method forces the carousel always to be 280 points across (that's the 100 multiplier on xPosition times two, plus the width of a view). So the finger tracking will appear to be far too slow if you run it on an iPad. The solution is obviously not to assume the view width is 180 degrees but that's left as an exercise!