KPMiniPickerView

In the continuing story of iPad development one of the issues that became obvious was that the UIDatePicker looked really lousy in the overall design of the hour entry application. The UIPickerView component that is used by UIDatePicker has a fixed skin that is hard to fit into another layout. This was mostly caused by the appearence it generates that it is a scroll wheel inside some kind of casing, but the casing has no outside border. So It really did not fit-in well.

Secondly the UIDatePicker takes up quite a bit of space on the screen but you cannot quickly pick another date in the same month; you have to scroll to it, possibly 30 days. I like the “normal” date pickers better. So I found the required excuse to write one.

Selecting a day somewhere in the month has become much quicker now and the UI is familiar to most user; it takes up a little bit more room than a UIDatePicker, but for that you get quick navigation. The only issue was that there still was a need to pick a year and month. Using the UIPickerView for this was not an option, because of the same problems. So I needed a picker that embedded more easily in a layout and required less space; a mini picker. They are visible at the top of the calendar picker; one for the year, one for the month. I chose a look that was similar to a regular text field, but instead of typing text, you can scroll the contents. The individual views are separated by a vertical divider bar:

It uses the KPOverlayView that was introduced in a previous blog to paint two small arrows to visually indicate that this is a minipicker, not a textfield. It also mimicks UIPickerView as closely as possible, so it has a delegate that provides the child views with an API that is almost identical to UIPickerView’s, except naturally that it does not send along a UIPickerView but a KPMiniPickerView. And for now it only supports one component (the UIPickerView can have multiple spinning wheels, aka components, next to each other); I’m still thinking about how to support multiple components in a mini picker style.

As an example of its uages, below is the code for the KPMonthMiniPickerView that uses the mini picker:

//
//  KPMonthMiniPickerView.m
//  DH2iPad
//
//  Created by Tom Eugelink on 13.02.11.
//  Copyright 2011 KnowledgePlaza. All rights reserved.
//

#import "KPMonthMiniPickerView.h"
#import "KPUtil.h"

@implementation KPMonthMiniPickerView

@synthesize monthSelectedListeners;

- (id)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];
    if (self) {
		// listeners
		monthSelectedListeners = [[KPListenerManager alloc] init]; // released in dealloc

        // create a picker
		_kpMiniPickerView = [[KPMiniPickerView alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height)]; // we alloc, and release in dealloc
		_kpMiniPickerView.delegate = self;
		_kpMiniPickerView.backgroundColor = [UIColor clearColor];
		[_kpMiniPickerView selectView:[self pickerView:_kpMiniPickerView numberOfRowsInComponent:1] / 2]; // always start in the middle
		[self addSubview:_kpMiniPickerView];
		_kpMiniPickerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;

		// init
		[self setMonth:1];
    }
    return self;
}

- (void)dealloc {
	[monthSelectedListeners release];
	[_kpMiniPickerView release];
    [super dealloc];
}

// set the selected month
-(void)setMonth:(int)value {
	[_kpMiniPickerView selectView:value - 1];
}

// get the selected month
-(int)getMonth {
	int lMonth =  [_kpMiniPickerView getSelectedViewIdx] + 1;
	return lMonth;
}

// ====================================================================================================================================
// KPMiniPickerViewDelegate 

- (NSInteger) numberOfComponentsInPickerView: (KPMiniPickerView *) pickerView {
	return 1;
}

- (NSInteger) pickerView: (KPMiniPickerView*) pickerView numberOfRowsInComponent: (NSInteger) component {
	return 12;
}

- (UIView *)pickerView:(KPMiniPickerView *)pickerView viewForRow:(NSInteger)row forComponent:(NSInteger)component reusingView:(UIView *)view{
	NSString* lDateString = [NSString stringWithFormat:@"2011-%d-01 00:00:00 +0:00", (component + 1)];
	NSDate* lDate = [NSDate dateWithString:lDateString];
	NSLocale *lLocale = [[NSLocale alloc] initWithLocaleIdentifier:@"nl_NL"];

	CGRect rect = CGRectMake(0, 0, 120, 80);
	UILabel *label = [[UILabel alloc]initWithFrame:rect];
	label.layer.borderColor = [UIColor grayColor].CGColor; label.layer.borderWidth = 1;
	label.text = [KPUtil formatDate:lDate as:@"MMM" for:lLocale];
	label.textAlignment = UITextAlignmentCenter;
	label.backgroundColor = [UIColor whiteColor];
	label.clipsToBounds = YES;

	[lLocale release];

	return label ;
}

- (void) pickerView:(KPMiniPickerView*)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component {
	[monthSelectedListeners notify:[NSNumber numberWithInt:[self getMonth]]];
}

@end

Leaves me to say that personally I’m very pleased with the result. This does not mean that there is no room for improvement. I want to start recognizing swipes, so you can scroll more easily, but as a basis this works quite nice. If anyone picks up this code and improves it, please send me any enhancements.

The header file:

//
//  KPMiniPickerViewer.h
//  DH2iPad
//
//  Created by TBEE on 08.02.11.
//  Copyright 2011 KnowledgePlaza. All rights reserved.
//

#import <UIKit/UIKit.h>
#import "KPOverlayView.h" 

@class KPMiniPickerView; // this is required to not have problems with circular references
@protocol KPMiniPickerViewDelegate<NSObject>
	@required
	- (NSInteger) numberOfComponentsInPickerView: (KPMiniPickerView*)pickerView;
	- (NSInteger) pickerView: (KPMiniPickerView*)pickerView numberOfRowsInComponent:(NSInteger)component;
	@optional
	- (UIView *)pickerView:(KPMiniPickerView *)pickerView viewForRow:(NSInteger)row forComponent:(NSInteger)component reusingView:(UIView *)view;
	- (void)pickerView:(KPMiniPickerView *)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component;
	- (CGFloat)pickerView:(KPMiniPickerView *)pickerView rowHeightForComponent:(NSInteger)component; // not used
	- (NSString *)pickerView:(KPMiniPickerView *)pickerView titleForRow:(NSInteger)row forComponent:(NSInteger)component; // not used
	- (CGFloat)pickerView:(KPMiniPickerView *)pickerView widthForComponent:(NSInteger)component; // not used
@end

@interface KPMiniPickerView : UIView {
	int _activeViewIdx;
	int _viewportPos; // assume all child views are stack, then this is the center of the viewport. By changing this value it can be calculated which views are visible how.
	CGPoint _touchStartPos;
	NSMutableDictionary* _indexToViewDictionary;
	KPOverlayView* _kpOverlayView;
	float _lastWidth;
}

@property(nonatomic, assign) id<KPMiniPickerViewDelegate> delegate;

-(void)selectView:(int)index;
-(int)getSelectedViewIdx;

@end

The actual source file:

//
//  KPMiniPickerViewer.m
//  DH2iPad
//
//  Created by TBEE on 08.02.11.
//  Copyright 2011 KnowledgePlaza. All rights reserved.
//
// This is a alternate implementation for UIPickerView.
// The main issue here is that UIPickerView not only takes up quite an amount of space on screen,
// but also is style explicitly, thus not easy to embed in differently themed apps.
// The minipicker view acts like a viewport on a horizontal string of views, like a ribbon.
//
// This class mimicks the API of UIPickerView, using a similar delegate as the UIPickerViewDelegate.
// But the current version only supports one component.
//
// Usage also is similar to KPPickerView:
//   _kpMiniPickerView = [[KPMiniPickerView alloc] initWithFrame:CGRectMake(...)];
//   _kpMiniPickerView.delegate = self; // self implements KPMiniPickerViewDelegate
//

#import "KPMiniPickerView.h"
#import <QuartzCore/QuartzCore.h>

@interface KPMiniPickerView (hidden)
	-(void)layoutForViewportPos:(int)viewportPos;
	-(UIView*)viewForIndex:(int)index offset:(int)offset;
	-(void)centerOnView:(int)index;
@end

@implementation KPMiniPickerView

@synthesize delegate;

- (id)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];
    if (self) {
		// storage
		_indexToViewDictionary = [[NSMutableDictionary dictionaryWithCapacity:5] retain];

		// styling
		self.backgroundColor = [UIColor whiteColor];
		self.layer.borderColor = [UIColor grayColor].CGColor;
		self.layer.borderWidth = 2;
		self.layer.cornerRadius = 10;
		self.clipsToBounds = YES;

		// overlay
		_kpOverlayView = [KPOverlayView overlayWithView:self drawMethod:@selector(drawOverlay:)]; // released when the view is removed from the subviews
		// but since we will be removing it and putting it back on top somehwre below, we need to keep hold of it ourselves
		[_kpOverlayView retain]; // released in dealloc
    }
    return self;
}

- (void)dealloc {
	[_indexToViewDictionary release];
	[_kpOverlayView release];
    [super dealloc];
}

- (void)layoutSubviews {
	// not initialized
	if (_viewportPos == 0) {
		[self selectView:_activeViewIdx];
		_lastWidth = self.bounds.size.width;
	}

	// width changed? (this is for handling rotations)
	if (_lastWidth != self.bounds.size.width) {
		[_indexToViewDictionary removeAllObjects]; // clear the cache
		[self centerOnView:_activeViewIdx]; // recalculate the viewportpos
		_lastWidth = self.bounds.size.width;
	}

	// just paint
	[self viewForIndex:_viewportPos offset:0];
}

// return the index of the currently selected view
-(int)getSelectedViewIdx {
	return _activeViewIdx;
}

// make the view at the specified index the active one
-(void)selectView:(int)index {
	int lOldViewIdx = _activeViewIdx;
	_activeViewIdx = index;
	[self centerOnView:_activeViewIdx];

	// notify delegate if changed
	if (lOldViewIdx != _activeViewIdx) {
		[delegate pickerView:self didSelectRow:1 inComponent:_activeViewIdx];
	}
}

// set the viewport pos so that it has the specified view index centered
-(void)centerOnView:(int)index {
	_viewportPos = (index * self.bounds.size.width) + (self.bounds.size.width / 2);
	[self layoutForViewportPos:_viewportPos];
}

// calculate which view idx is most visible by the specified pos
-(int)posToView:(int)viewportPos {
	int lViewCnt = [delegate pickerView:self numberOfRowsInComponent:1];
	int lIdx =  (int)(viewportPos / self.bounds.size.width);
	if (lIdx < 0) lIdx = 0; 	if (lIdx >= lViewCnt-1) lIdx = lViewCnt - 1;
	return lIdx;
}

// return the corresponding via for the specified view index (adding the offset to that index)
-(UIView*)viewForIndex:(int)index offset:(int)offset {
	// cache key
	NSString* lKey = [NSString stringWithFormat:@"%d", (index + offset)];

	// get from cache
	UIView* lView = [_indexToViewDictionary objectForKey:lKey];
	if (lView != nil) return lView;

	// create new
	lView = [delegate pickerView:self viewForRow:0 forComponent:(index + offset) reusingView: nil];
	lView.frame = CGRectMake(offset * self.bounds.size.width, 0, self.bounds.size.width, self.bounds.size.height);

	// store in cache
	[_indexToViewDictionary setObject:lView forKey:lKey];

	// also clear some objects if the cache becomes too full
	int lClearCacheIdx = (index + offset) - 10; // TODO: make 10 configurable?
	lKey = [NSString stringWithFormat:@"%d", lClearCacheIdx];
	while ([_indexToViewDictionary objectForKey:lKey] != nil) {
		[_indexToViewDictionary removeObjectForKey:lKey];
		lClearCacheIdx--;
		lKey = [NSString stringWithFormat:@"%d", lClearCacheIdx];
	}
	lClearCacheIdx = (index + offset) + 10; // TODO: make 10 configurable?
	lKey = [NSString stringWithFormat:@"%d", lClearCacheIdx];
	while ([_indexToViewDictionary objectForKey:lKey] != nil) {
		[_indexToViewDictionary removeObjectForKey:lKey];
		lClearCacheIdx++;
		lKey = [NSString stringWithFormat:@"%d", lClearCacheIdx];
	}

	// done
	return lView;
}

// layout the views of the childeren for the specified viewport pos
-(void)layoutForViewportPos:(int)viewportPos {
	// determine the views that are visible
	int lCenterViewIdx = [self posToView:viewportPos];
	int lCenterViewViewportPos = (lCenterViewIdx * self.bounds.size.width) + (self.bounds.size.width / 2);
	int lDeltaX = lCenterViewViewportPos - viewportPos;

	// get visible views
	int lViewCnt = [delegate pickerView:self numberOfRowsInComponent:1];
	UIView* lViewCenter = [self viewForIndex:lCenterViewIdx offset:0];
	UIView* lViewBefore = lCenterViewIdx <= 0 ? nil : [self viewForIndex:lCenterViewIdx offset:-1]; 	UIView* lViewAfter = lCenterViewIdx >= (lViewCnt-1) ? nil : [self viewForIndex:lCenterViewIdx offset:+1];

	// clear up the childeren so only these three are present
	// this also removes the overlay! We want this so we can add it again and it will be on top
	for (UIView* lView in self.subviews) {
		if (lView != lViewBefore && lView != lViewCenter && lView != lViewAfter) {
			[lView removeFromSuperview];
		}
	}	

	// add any new subviews
	if (lViewBefore != nil && [self.subviews containsObject:lViewBefore] == NO) { [self addSubview:lViewBefore]; }
	if (lViewCenter != nil && [self.subviews containsObject:lViewCenter] == NO) { [self addSubview:lViewCenter]; }
	if (lViewAfter != nil && [self.subviews containsObject:lViewAfter] == NO) { [self addSubview:lViewAfter]; }
	// add the overlay again last so it is on top
	[self addSubview:_kpOverlayView]; 

	// animate the views to their positions
	CGRect lCGRect = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height);
	[UIView beginAnimations:nil context:NULL];
	lCGRect.origin.x = lDeltaX;
	if (lViewCenter != nil) lViewCenter.frame = lCGRect;
	lCGRect.origin.x = lDeltaX - self.bounds.size.width;
	if (lViewBefore != nil) lViewBefore.frame = lCGRect;
	lCGRect.origin.x = lDeltaX + self.bounds.size.width;
	if (lViewAfter != nil) lViewAfter.frame = lCGRect;
	[UIView commitAnimations];
}

// draw
- (void)drawRect:(CGRect)rect {
	// if there is no child, set it
	if (self.subviews.count == 0) [self selectView:_activeViewIdx];

	// do it
	[super drawRect:rect];
}	

// draw the overlay
- (void)drawOverlay:(CGRect)rect {
	int lXMargin = 6;
	int lArrowWidth = 10;
	int lArrowHeight = 10;
	int lYMargin = (self.bounds.size.height - lArrowHeight) / 2;

	// get context
	CGContextRef ctx = UIGraphicsGetCurrentContext();

	// setup pen
	CGContextSetRGBStrokeColor(ctx, 0.18, 0.38, 0.86, 0.5);
	CGContextSetLineWidth(ctx,2.0);

	// draw left arrow
	CGContextMoveToPoint(ctx, lXMargin + lArrowWidth, lYMargin);
	CGContextAddLineToPoint( ctx, lXMargin, lYMargin + (lArrowHeight / 2) );
	CGContextAddLineToPoint( ctx, lXMargin + lArrowWidth, lYMargin + lArrowHeight);

	// draw right arrow
	CGContextMoveToPoint(ctx, self.bounds.size.width - lXMargin - lArrowWidth, lYMargin);
	CGContextAddLineToPoint( ctx, self.bounds.size.width - lXMargin, lYMargin + (lArrowHeight / 2) );
	CGContextAddLineToPoint( ctx, self.bounds.size.width - lXMargin - lArrowWidth, lYMargin + lArrowHeight);

	//"stroke" the path
	CGContextStrokePath(ctx);
}	

// Handles the start of a touch
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
	// Enumerate through all the touch objects.
	for (UITouch* lTouch in touches) {
		_touchStartPos = [lTouch locationInView:self];
	}
}

// Handles the continuation of a touch.
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {

	// reposition child
	for (UITouch* lTouch in touches) {
		CGPoint lTouchPos = [lTouch locationInView:self];

		// how much did we move horizontally
		CGFloat lDeltaX = lTouchPos.x - _touchStartPos.x;

		// determine where we are in the index
		int lNewviewportPos = _viewportPos - lDeltaX;

		// also move the view that much horizontally
		[self layoutForViewportPos:lNewviewportPos];
	}
}

// Handles the end of a touch event.
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
	for (UITouch* lTouch in touches) {
		CGPoint lTouchPos = [lTouch locationInView:self];

		// how much did we move horizontally
		CGFloat lDeltaX = lTouchPos.x - _touchStartPos.x;

		// determine where we are in the index
		int lNewviewportPos = _viewportPos - lDeltaX;

		// determine the view that would be central
		int lCenterViewIdx = [self posToView:lNewviewportPos];

		// center that
		[self selectView:lCenterViewIdx];
	}
}

// if the touch was cancelled, reset to a valid state
-(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
	[self layoutForViewportPos:_viewportPos];
}

@end

This Post Has 3 Comments

  1. vick

    very good idea — would like to play with it and if possible improve and give back — can u post a working xcode project to see how it all comes together and explore the sub files in the header — great work to address this — have been on the back of my mind the moment i lost the first awe with the wheel – thanks

    1. tbeernot

      Basically the .h and .m file above is all there is to the minipicker. Just copy paste it in your project and off you go. And the month picker at the top is a good example on how to use it.
      The biggest problem I have so far is that on the emulator the drag event is continued when the finger leaves the control, but on the actual iphone / ipad the drag is lost. It’s still on my TO DO list to see if I can fix that.

      If you’re interested inthe complete calendar picker; it is finished, works with only the limitation I just mentioned, All that is needed is a blog post. Maybe I will write one tonight, otherwise I’ll email the code.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.