From JS to UICollectionView – Building a custom Roulette Wheel Stack Control in iOS

slide 4
slide 4

Update: Super glad to have this control accepted by CocoaControls! Thanks guys! http://www.cocoacontrols.com/controls/roulettewheelcollectionview

 

This would probably be the last post I’ll write with reference to the Yahoo TimeTraveler app, and I want to make it interesting by showing how the implementation of a custom UIControl evolved throughout time.

The control we were trying to build was a roulette wheel of city cards. I guess the idea came from spinning a globe and deciding on your next travel destination based on where your finger landed. Simple enough, but when it came to building the damn thing it was anything but.

A video of the control in action, from release v2.0.2

First Try – HTML and Javascript

The app started life as a hybrid app with most of its UI done with HTML. When the page began to load, an initialization method looped through an array of elements, calculated each element’s rotation angle (n/360), and applied it to the element’s style attribute.

skeleton of the roulette wheel

skeleton of the roulette wheel

The next step was to create a swipe gesture recognizer on the main div element, and add logic to animate the cards on both sides in and out of the viewport when a swipe occurs.

As seen in the clip below, this approach seemed to work pretty well. But there were obvious issues like jerkiness in the scrolling, missed strokes, and absence of inertia. It had a long way to go to reach the smoothness and speed of custom controls like iTunes’ coverflow.

Second Try – Going Native

Taking a cue from the javascript approach, I began with a plain UIView as the blank canvas, added a pile of UIImageViews on top, and applied CGAffineTransform rotations to each UIImageView around the center of the view. Then I finished with a UIPanGestureRecognizer which rotated the entire UIView upon swiping. The resulting construct looked like this:

roulette wheel - uiview

sketch of the UIView roulette view in Keynote

There was one big caveat. In the original design, the cards on the right had to be always stacked on the top. I tried to do so in scrollViewDidScroll:, using the UIImageView’s frame origin to work out which cards were in the viewport. However, I didn’t realize that the frame size of a UIView would be distorted after a transformation, so determining whether a transformed cell lied within a certain boundary was super difficult.

The idea of using UIView animation and GestureRecognizers also backfired. It was near impossible to cancel on-going animations and start new ones based on user input. Suppose a user does a quick flick, then taps on a card. He would expect the wheel to stop spinning and the card slowly lock to the upright position when the touch was released. Firing off UIView animations wasn’t the way to do it. On the other hand, not using UIView animations meant there was no easy way to add inertia to the scroll.

Third Try – Using a UIScrollView

With little point in pursuing that design further, I turned to my then-manager for advice. “It doesn’t necessarily have to be a wheel, it just have to look like one”. He said.

That got me thinking in a different direction.

He suggested looking into implementations of coverflow, which can be interpreted to having very similar requirements as we did, with the major difference being coverflow making use of 3D perspective transforms and us 2D rotations. There is also the added advantage of having silk smooth animations (with inertia, can’t stress that enough) backed by the almighty UIScrollview.

Restrained from coding right away, I prototyped on paper and in Keynote (unorthodox, but super effective) — laying out the cards in a horizontal table (slide 1), applying varying degrees of rotation to each (slide 2), and figuring out what levels of translations were needed to make the cards appear like a wheel (slide 3). I then needed to find the offsets so I can deduce where the cards were by covering the design with a 1:1 replica of the iPhone chrome (slide 4).

slide 1

slide 1

slide 2

slide 2

slide 3

slide 3

slide 4

slide 4

After getting comfortable with the constraints time was spent building a prototype. Unfortunately the code can’t be shown here but they were the usual UITableViewCell and UITableView subclasses. To prevent cards from “popping in” when views were queued and de-queued, the viewport had to be extended a bit, 50px on each side to be exact.

Here’s the version we shipped with. We must have gone through 5-6 builds until we all felt pleased with it.

Fourth Try – UICollectionView

The seed for re-implementing the roulette wheel control in UICollectionView had been planted in my head since I saw the demo in WWDC. When I actually got round to doing it, it was rather straightforward thanks to tutorials from Ray Wenderlich and various github projects. Subclassing UICollectionViewFlowLayout and overriding layoutAttributesForElementsInRect:rect was all I had to do.

The result:

The layout subclass where 99% of the logic resided:

Conclusion

Using UICollectionView was awesome. Not only was the animation super smooth (helped by the god-send shouldInvalidateLayoutForBoundsChange:), and you get features like center snapping for free. It drastically reduced the amount of custom code I had to write, and offered far greater performance with FPS going through the roof. UICollectionView was definitely the greatest developer feature of iOS6.

A little bit of LOC comparison:

HTML – 1385 lines of javascript, not including the YUI framework and related CSS.
UIView – ? (code was thrown away)
UIScrollView – 945 lines
UICollectionFlowLayout – 213 lines

To close off, here’s what I learned from the year long experience of implementing this custom iOS control with different approaches.

  • Work from native UIKit elements where possible.
  • Cheat! Yes, really. There’s a difference between cutting corners and doing something clever.

Remember, it doesn’t necessarily have to be a wheel, it just need to look like one.

Downloads

A demo app of the UICollectionView version of the wheel can be found at https://github.com/kenshin03/RouletteWheelCollectionViewDemo.

References:

About these ads

7 Comments

  1. Pingback: Source Code Example Demonstrating How To Create A Rotating Image Wheel Using UICollectionView

  2. I am implementing this in my iOS app, is there a way to know which image is snapped to the center without the user specifically tapping it? I am using this for a user to scroll through images and tap a share button, so I would like to automatically use the current image in the view.

    • Hi Jo, there are several ways to go about this:

      1. In the method layoutAttributesForElementsInRect:, an array of cells in the specified viewport is given and for each cell, the amount of rotation to apply is deduced. Therefore, we simply have to compare these cells and find the one with the minimum rotation amount.

      – (NSArray*)layoutAttributesForElementsInRect:(CGRect)rect {
      NSArray * array = [super layoutAttributesForElementsInRect:rect];

      NSMutableArray * modifiedLayoutAttributesArray = [NSMutableArray array];

      __block float mininumRotatedByValue = 10.0f;
      __block NSIndexPath * minimumRotatedCellIndexPath = nil;
      CGFloat horizontalCenter = (CGRectGetWidth(self.collectionView.bounds) / 2.0f);
      [array enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes * layoutAttributes, NSUInteger idx, BOOL *stop) {

      CGPoint pointInCollectionView = layoutAttributes.frame.origin;
      CGPoint pointInMainView = [self.superView convertPoint:pointInCollectionView fromView:self.collectionView];

      CGPoint centerInCollectionView = layoutAttributes.center;
      CGPoint centerInMainView = [self.superView convertPoint:centerInCollectionView fromView:self.collectionView];

      float rotateBy = 0.0f;
      CGPoint translateBy = CGPointZero;
      if (pointInMainView.x < self.collectionView.frame.size.width+80.0f){
      translateBy = [self calculateTranslateBy:horizontalCenter attribs:layoutAttributes];
      rotateBy = [self calculateRotationFromViewPortDistance:pointInMainView.x center:horizontalCenter];
      NSLog(@"rotateBy: %f", rotateBy);
      if (abs(rotateBy) < mininumRotatedByValue){
      mininumRotatedByValue = rotateBy;
      minimumRotatedCellIndexPath = layoutAttributes.indexPath;
      }

      // … abbreviated
      }
      }];
      NSLog(@"minimum rotated cell: %i", minimumRotatedCellIndexPath.item);
      return modifiedLayoutAttributesArray;
      }

      2. UICollectionView is a scrollView, so you can make your class a delegate of the collection view, and implement – (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView and – (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate. Inside, you can loop through the array of visible cells and use their attributes to determine which card is the center card:

      – (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
      NSArray * visibleCards = self.collectionView.visibleCells;
      [visibleCards enumerateObjectsUsingBlock:^(RVCollectionViewCell * visibleCell, NSUInteger idx, BOOL *stop) {
      NSLog(@"visible cell: %@", visibleCell.imageName);
      }];
      }

      Hope this helps!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s