As engineers, we should attempt to cross the boundary of developers vs. front-end UI design engineers. Typically, people who write code are not very good UI engineers because they just don’t have the experience. It isn’t that we want to make a bad interface, we are just very accustomed to using standard UI components. However, the standard UI components can’t always get the job done in the most effective or user friendly manner. This was precisely the case when I needed to come up with a quick, intuitive way to filter information in one of my iPhone applications.

The horizontal scroll wheel is great because it is built out of standard UI components (UIImageView and UIScrollView) and there is no subclassing to be done to make the widget work. It can be dropped into any application. There is little code to support the layout and generation of the scroll wheel, and all layout calculations are done dynamically so you can have as many or as few items in the list as desired. Also, you can easily create additional value with the audio framework and Core Animation that is extremely simple to do.

For users, it is great because it is an easy to use and understand, quick method to filter a UITableView. The component requires no explanation. A sign of good UI design is a WYSIWYG type approach where the interface is so intuitive it is a joy to use. Create components that users can’t stop playing with.

Let’s get started already. By the way, this tutorial assumes you know Objective-C, Cocoa and basic programming principles. I welcome feedback on any of the code.

ScrollWheel.h

#import <UIKit/UIKit.h>;
#import "ChannelCategory.h"
#import "NSArray+FirstObject.h"
#import "Colors.h"
#import "Debugger.h"

#define SCROLL_WHEEL_CATEGORY_CHANGED @"ScrollWheelCategoryChanged"
#define SCROLL_WHEEL_CATEGORY_ADDED @"ScrollWheelCategoryAdded"

@interface CategoryScrollWheel : UIView <UIScrollViewDelegate> {
  IBOutlet UIScrollView *_scrollView;
}

@property (nonatomic, retain) IBOutlet UIScrollView *_scrollView;

- (void) setupCategories: (NSArray *) categories;
- (void) scrollToButton: (id) sender;
- (void) rebuildCategories;
- (NSArray *) loadNewsCategories;
@end

ScrollWheel.m

#import "CategoryScrollWheel.h"

@implementation CategoryScrollWheel

@synthesize _scrollView;

- (id)initWithFrame:(CGRect)frame {
  if ((self = [super initWithFrame:frame])) {
    // Initialization code
  }
  return self;
}

- (void) rebuildCategories {
  // release the previous wheel and alloc a new one

  [_scrollView release], _scrollView = nil;
  _scrollView = [[UIScrollView alloc] init];

  // reload the categories into the scroll wheel

  [self setupCategories: [NSArray arrayWithArray: [self loadNewsCategories]]];
}

- (NSArray *) loadNewsCategories {
  NSManagedObjectContext *managedObjectContext = [(RSSAppDelegate *)[[UIApplication sharedApplication] delegate] managedObjectContext];

  NSFetchRequest *request = [[NSFetchRequest alloc] init];
  NSError *error;
  NSArray *fetchResults;
  NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:NO];
  NSArray *sortDescriptors = [[NSArray alloc] initWithObjects: sortDescriptor, nil];

  [request setEntity: [NSEntityDescription entityForName:@"ChannelCategory" inManagedObjectContext: managedObjectContext]];
  [request setSortDescriptors: sortDescriptors];

  if ((fetchResults = [managedObjectContext executeFetchRequest: request error: &amp;error]) == nil) {
    ALog("%@", error);
    [request release], request = nil;
    [sortDescriptor release], sortDescriptor = nil;
    [sortDescriptors release], sortDescriptors = nil;
    return nil;
  }

  [sortDescriptor release], sortDescriptor = nil;
  [sortDescriptors release], sortDescriptors = nil;

  if([fetchResults count] == 0) {
    // no feeds to refresh
    [request release], request = nil;
    return nil;
  }
  else {
    return fetchResults;
  }
}

- (void) setupCategories: (NSArray *) categories {
  // our variable x will keep track of how far our scroll view extends
  // we need to add a half scroll view width to get the arrow in the middle to
  // be able to center up

  double x = ([self _scrollView].frame.size.width / 2);
  double lastHoldover = 0;
  
  for(ChannelCategory *category in categories) {
    UIButton *button = [[UIButton alloc] init];
    [button setTitle: [category name] forState: UIControlStateNormal];
    [[button titleLabel] setFont: [UIFont fontWithName: @"Helvetica-Bold" size: 11]];
    [[button titleLabel] setTextColor: UI_COLOR_LIGHT_GREY];
    
    CGRect rect = CGRectMake(x, 0, [[category name] sizeWithFont: [UIFont fontWithName: @"Helvetica-Bold" size: 11]].width, 30);

    if([category isEqual: [categories firstObject]]) {
      x -= (rect.size.width / 2);
      rect.origin.x = x;
    }

    [button setFrame: rect];
    [button addTarget: self action: @selector(scrollToButton:) forControlEvents: UIControlEventTouchUpInside];

    [[self _scrollView] addSubview: button];

    x += button.frame.size.width + 10;

    if([category isEqual: [categories lastObject]]) {
      lastHoldover = (button.frame.size.width / 2);
      // bad hack to lop off the extra 10 on the last item
      x -= 10;
    }

    [button release], button = nil;
  }

  // this adds a half scrollview width to the end of the scrollview

  x += ([self _scrollView].frame.size.width / 2);
  x -= lastHoldover;

  CGSize size = CGSizeMake(x, 30);

  [[self _scrollView] setContentSize: size];
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
  // we add the offset and half the view together to bring it into line with the
  // arrow in the middle
  double val = [[self _scrollView] contentOffset].x + ([self _scrollView].frame.size.width / 2);

  // loop through and see which button is closest.  snap to that button
  // using scrollToButton

  UIButton *closestButton;
  double closest = 100;

  for (UIButton *view in [[self _scrollView] subviews]) {
    if ([view isKindOfClass:[UIButton class]]) {
      double calculatedValue = abs(view.frame.origin.x -  val);

      if(calculatedValue &lt; closest) {
        closestButton = (UIButton *)view;
        closest = calculatedValue;
      }
    }
  }

  CGPoint offset = closestButton.frame.origin;
  CGRect scrollViewFrame = [[self _scrollView] frame];
  offset.x -= (scrollViewFrame.size.width / 2) - (closestButton.frame.size.width / 2);

  [[self _scrollView] setContentOffset:offset animated:YES];
  [[NSNotificationCenter defaultCenter] postNotificationName: SCROLL_WHEEL_CATEGORY_CHANGED object: [[closestButton titleLabel] text]];
}

- (void) scrollToButton: (id) sender {
  CGPoint offset = ((UIButton *)sender).frame.origin;
  CGRect scrollViewFrame = [[self _scrollView] frame];

  offset.x -= (scrollViewFrame.size.width / 2) - (((UIButton *)sender).frame.size.width / 2);

  [[self _scrollView] setContentOffset:offset animated:YES];
  [[NSNotificationCenter defaultCenter] postNotificationName: SCROLL_WHEEL_CATEGORY_CHANGED object: [[sender titleLabel] text]];
}

/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
  // Drawing code
}
*/

- (void)dealloc {
  [super dealloc];
}

@end

ScrollWheel.xib

In my ScrollWheel.xib, I have 2 UIImageViews and a UIScrollView. Literally, it is that easy. The image view is meant to create a nice frame around the scroll view giving the illusion of “limited view.” The top UIImageView may even have a small arrow pointing down to indicate which item is currently selected. The UIScrollView is the one referenced in the code above.

So there it is – a neat, clean, compact way of taking standard UI components and turning it into a custom UI to allow for selection of categories. If you have any questions or comments on how to improve the code, please let me know!

« »