Click here to Skip to main content
Click here to Skip to main content

Switch between views Mobile Safari-style

, 23 Jul 2010 CPOL
Rate this:
Please Sign up or sign in to vote.
A reusable library (specifically, an UIViewController subclass) to implement Mobile Safari page/tab switching interface in your own app. Now supports orientation changes!

Introduction

Mobile Safari provides an alternative look to tabs - pages that line up neatly when you tap that right-most button. After that, you just flick left, flick right to choose the page you want, tap one more time and the page scales up to fill the screen. Beautiful interface. Too bad there is no easy-to-use UIViewController subclass from Apple to achieve the same interface.

Introducing... LSPageViewController!

In this article, I'll show you how to use LSPageViewController to mimic Mobile Safari's page switching interface in your own app, and how to customize that interface the way you want.

viewswitcher.png

Prerequisites

  • Some knowledge of Objective-C. Not too much, but enough to write a HelloWorld app with a button that displays a smiling monkey when tapped.
  • Xcode with iPhone SDK 4.0 or newer. Tested on 4.0.

What I have learned along the way

During the journey of making this project, I put almost all of my focus on Core Animation, and my knowledge of implicit and explicit animations has been greatly improved. Also, I learned briefly about drawing custom content for a layer (the gradient background, the text and the dots) - and the fact that you need to call - setNeedsDisplay on every single layer with custom drawing methods, unlike UIViews. Finally, I learned about the great importance of UIViewControllers in an iPhone app.

New in 4.0: Blocks. Called "Lambda expressions" in C#, I believe. Blocks greatly simplified LSPagesPresentationView's implementation, since there is a + [CATransaction setCompletionBlock:] to replace the traditional callbacks and delegate methods (you'll find that - (void)animationDidStop:finished: is no longer implemented in LSPagesPresentationView). Of course, blocks are much more useful than that. I'd suggest you read Apple's documentation for more information.

Also, UIGestureRecognizer's concrete subclasses allow you to implement different gesture recognition algorithms for your view(s) without too much work.

Well, so much for the talking. Time to explore!

Tip of the iceberg

Grab yourself a copy of the source code, then open the project in Xcode. You can see that a number of classes reside in a group called "Main guts". Copy those classes over to your project and you're ready to use them. You'll be directly interacting with LSPageViewController only - LSPageViewController will manage the other classes.

The first thing to do is to make an instance of LSPageViewController. You can choose to add an instance of LSPageViewController to a nib file, create a separate nib file with an instance of LSPageViewController as the owner, or even do the work programmatically.

After that, you need to display LSPageViewController's view. You have several options here:

  • You can make LSPageViewController the main view controller by adding it to MainWindow.xib (or whatever NIB file you put your main window and application delegate in). Then, in your application delegate, you can configure LSPageViewController (setting frame, position, etc.) and add its view as a subview of the window. This approach is similar to the Navigation-based Application template in Xcode, where an UINavigationController is added to MainWindow.xib and hooked to the application delegate.
  • LSPageViewController's view can be a subview of another view controller (not the main window's subview). This solution is demonstrated in the example project. Please note that because LSPageViewController is not the main view controller, your main window will not forward some important messages to it (for example, - (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration). Your main view controller is responsible for forwarding these messages. (For references, read the MainViewController class' implementation in the example project).
  • Create an instance of LSPageViewController programmatically, then add its view as a subview to a window or another view.

There are two ways to manipulate view controllers managed by LSPageViewController:

  • Call - [LSPageViewController addViewController:] to add new view controllers, - [LSPageViewController insertViewController:atIndex:] to insert a view controller to a specified location, and - [LSPageViewController removeViewControllerAtIndex:] to remove a view controller. You can also call - [LSPageViewController numberOfViewControllers] to check the number of view controllers managed by LSPageViewController.
  • Grab a reference to the NSMutableArray viewControllers that LSPageViewController uses to store view controllers by accessing the read-only property viewControllers. You can then add, insert, remove... however you like.

The difference between those two ways is that by calling LSPageViewController's methods, if the presentation view is being shown, LSPageViewController will have the chance to trigger necessary animations. When manipulating the NSMutableArray viewControllers directly, no animations will be shown regardless of the presence of the presentation view.

When you want to present all views for the user to pick (I call it the "presentation view"), invoke - [LSPageViewController displayPagesPresentationView].

And when you want to dismiss the aforementioned presentation view, just invoke - [LSPageViewController shouldDismissPagesPresenationView]

You can query the number of view controllers that the instance of LSPageViewController is managing by invoking - [LSPageViewController numberOfViewControllers]. To check if the presentation view is being shown, see LSPageViewController's isShowingAllView property.

A few notes:

You can assign an object to be LSPageViewController's delegate by accessing its delegate property. This object will be notified when the presentation view is successfully displayed (after invoking - [LSPageViewController displayPagesPresentationView]) and when the presentation view is dismissed (after calling - [LSPageViewController shouldDismissPresentationView]).

When the presentation view is successfully displayed, the delegate will receive a - (void)didShowPresentationView:(LSPagesPresentationView *)obj, and when the presentation view is dismissed, the delegate will receive a - (void)presentationViewDismissed.

Any UIViewController supplied to LSPageViewController should implement the method - (NSString *)presentationName - the string returned is displayed as the name of the view in the above screenshot. Alternatively, the UIView of the supplied UIViewController can implement that method. However, the UIViewController's method will be preferred over the UIView's (That means, if both implements - (NSString *)presentationName, the UIViewController's method will be called). If neither the UIViewController or the UIView of the UIViewController implement this method, the name will be set to "@Dev: name?".

You should make sure the UIViews managed by UIViewControllers that you supply to LSPageViewController are able to adapt to different sizes, because they will be resized to the same size as the LSPageViewController-managed view's. As an alternative, you can make sure both those views and the LSPageViewController-managed view are of the same size.

Also, you don't need to interact directly with LSPagesPresentationView. Ever. LSPageViewController will handle the work. You can, however, customize that class to change the behaviors and the look of the presentation view any way you like, as described in the following section.

New in 4.0: You can enable shadows to make the interface looks more pleasing by setting the useShadow property to YES. Note that by default, shadows are disabled since there could be a performance hit - although I'm not able to test this because I can't debug on my iPod. If anybody has access to the iPhone Developer Program, I will appreciate it if you try my project on your device(s) with shadows enabled to see if performance is affected.

Under the hood

LSPageViewController is just a regular subclass of UIViewController. Its mission is to handle adding and removing view controllers, and display and dismiss the presentation view when needed. When displaying the presentation view, it also has the mission of feeding the presentation view with CALayers representing the views to be shown to the users:

- (CALayer *)layerForViewController:(UIViewController *)vc
{
	UIImage *viewImage = [self imageRepresentationForViewController:vc];
	
	// Construct layer
	CALayer *viewImageLayer = [CALayer layer];
	viewImageLayer.frame = vc.view.bounds;
	viewImageLayer.contents = (id)[viewImage CGImage];
	if (self.useShadows)
	{
		viewImageLayer.shadowOpacity = 0.5f;
		viewImageLayer.shadowRadius = 4.0;
		viewImageLayer.shadowOffset = CGSizeMake(1.0, 3.0);
	}
	
	// Add close button layer
	CloseButtonLayer *closeButtonLayer = [CloseButtonLayer layer];
	closeButtonLayer.name = @"closeButton";
	closeButtonLayer.hidden = NO;
	closeButtonLayer.opacity = 0.0f;
	closeButtonLayer.opaque = YES;
	closeButtonLayer.bounds = CGRectMake(0.0f, 0.0f, 25.0f, 25.0f);
	closeButtonLayer.position = CGPointMake(0.0f, 0.0f);
	[viewImageLayer addSublayer:closeButtonLayer];
	
	// Obtain name to display to the user:
	// We'll search both the view controller and its view to see if they can provide the name.
	// If no name is found, we'll simply display @"@Dev: Name?" (Well I'm addicted to Twitter. Sue me.)
	if (([vc respondsToSelector:@selector(presentationName)]) && ([vc performSelector:@selector(presentationName)] != nil) && ([vc performSelector:@selector(presentationName)] != @""))
		viewImageLayer.name = [vc performSelector:@selector(presentationName)];
	else if (([vc.view respondsToSelector:@selector(presentationName)]) && ([vc.view performSelector:@selector(presentationName)] != nil) && ([vc.view performSelector:@selector(presentationName)] != @""))
		viewImageLayer.name = [vc.view performSelector:@selector(presentationName)];
	else
		viewImageLayer.name = @"@Dev: name?";
	
	return viewImageLayer;
}

(For those who need it, here's the implementation for - (UIImage *)imageRepresentationForViewController:(UIViewController *)vcSmile | :)

- (UIImage *)imageRepresentationForViewController:(UIViewController *)vc
{
	// Take a screenshot of the view controller's view
	// by telling the main layer of that view
	// to render in a custom context
	UIGraphicsBeginImageContext(vc.view.frame.size);
	[vc.view.layer renderInContext:UIGraphicsGetCurrentContext()];
	UIImage *viewImage = UIGraphicsGetImageFromCurrentImageContext();
	UIGraphicsEndImageContext();
	
	return viewImage;
}

From the snippet, you can see that the layer is created by telling the view to draw into a custom context, grabbing an UIImage representation from that context - in short, grabbing a screenshot of the view programmatically, then assigning the content property of the CALayer to the image. The other 2 sections just add a close button and set the name to be shown to the user.

LSPagesPresentationView, on the other hand, is much more complex. When initiated, it receives an array populated with CALayers representing views. LSPageViewController, after setting proper values, sends the presentation view a - [LSPagesPresentationView setup]:

// A multitude of setups
- (void)setup
{
	[[UIApplication sharedApplication] beginIgnoringInteractionEvents];
 
	[self.layer setNeedsDisplay];
 
	isLayingOutLayers = YES;
 
	[self setupNameLayer];
	[self setupDotLayer];
	[self setupLayers];
	[self prepareForEntranceAnimation];
}

Here's a simple diagram detailing the layout of a presentation view:

LSPagePresentationView_layout.png

The first two setup methods are pretty self-explanatory. The third one creates and positions a container layer, and then set all view-representing layers to be sublayers of that container layer, while also setting their positions and bounds (relative to the container layer, of course). The fourth one essentially resizes the currently selected view's representative layer to fill the screen (actually, the root view) to prepare for the entrance (shrinking) animation.

After that, LSPageViewController just set the presentation view to be the only subview of its root view. Then, it calls - [LSPagesPresentationView startEntranceAnimation] and the currently selected view's layer shrinks into place, revealing the other views, the text and dot layers.

Animations can be triggered by touch events: taps and swipes recognized by gesture recognizers and other touch events that the recognizers couldn't recognize. Gesture recognizers are added to the instance of LSPagesPresentationView at initiation:

	// From - (id)initWithFrame:layers:
	
	// Set up gesture recognizers
	// Tap recognizer
	UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleSingleTap:)];
	tapRecognizer.cancelsTouchesInView = YES;
	[self addGestureRecognizer:tapRecognizer];
	[tapRecognizer release];

There is only one gesture recognizer added to the view, and it is a tap recognizer. This is a simplified list of actions that will occur when a touch event is generated for an instance of LSPagesPresentationView:

  • UIApplication sends a touch event to the front window.
  • The front window forwards that event to our view. But before receiving the event, the tap recognizer will process it first.
  • Our view then receives the event if the tap recognizer hasn't recognized (or failed to recognize) the gesture.
  • If the tap recognizer successfully recognized a gesture, - (void)touchesCancelled:forEvent will be called instead.

By reading the code, you'll see that - (void)touchesEnded:forEvent (which is only called if the tap recognizer officially fail to recognize a particular tap gesture) animate the layers to the next or previous location based on the movement of the finger. On the other hand, - (void)touchesCancelled:forEvent is either called when the tap recognizer recognizes a tap or when another action cancels the event (incoming phone call, for example) - and it deals with the two kinds of situation differently (doing nothing if the cancellation comes from the tap recognizer while animating to original location if the cancellation originated from something else).

All animations used in LSPagesPresentationView are implicit animations. Although they are implicit by nature, they can be very powerful. Here's an example of animations that will be triggered when an user taps the left area:

	// From - (void)handleSingleTap:
	
	// Animate to previous layer if it exists
	if (self.previousLayer != nil)
	{
		self.selectedIndex -= 1;
	}
	
	[CATransaction begin];
	[CATransaction setAnimationDuration:0.5];
	[CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]];
	
	// Move all layers
	for (int i = 0; i < [pagesLayers count]; i++)
	{
		// Change position
		CALayer *layer = [pagesLayers objectAtIndex:i];
		layer.position = [self positionForLayerAtIndex:i];
		
		// Change opacity
		layer.opacity = (i == self.selectedIndex) ? 1.0 : 0.4;
		
		// Show close button if necessary
		[layer sublayerWithName:@"closeButton"].opacity = ((i == self.selectedIndex) && (self.shouldShowCloseButton)) ? 1.0 : 0.0;
	}
	
	// Update name and dot layer
	self.nameLayer.string = self.currentLayer.name;
	self.dotLayer.activeDot = self.selectedIndex;
	
	[CATransaction commit];

By simply assigning new values to various properties of the layers and grouping them inside + [CATransaction begin] and + [CATransaction commit], the layers will smoothly animate into their appropriate positions. Side note: since - (CGPoint)positionForLayerAtIndex: rely on selectedIndex, by updating selectedIndex, we will be able to generate appropriate positions for each and every layer managed by LSPagesPresentationView.

What about callbacks? What if you want to chain multiple animations together, or perform some tasks after the animations have completed? Prior to 4.0, you would have to make explicit CAAnimations, set the delegate property of one of those to self, and implement a - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)fin. Even worse, as you set up more and more animations with callbacks, your - animationDidStop:finished: will grow larger and larger. In the end, that delegate method becomes a huge tyrannosaurus that is bound to be unreadable by humans.

Luckily, in 4.0, as blocks are introduced, CATransaction sports a new class method: + [CATransaction setCompletionBlock:]. This replaces the need for clunky callbacks, and greatly simplify your code. Take a look at this snippet when you add a new view-representing layer to the instance of LSPagesPresentationView:

	- (void)addNewLayer:(CALayer *)layer
	{	
		// Add the new layer first
		layer.bounds = [self boundsForViewLayer];
		layer.opacity = 0.0f;
		[pagesLayers addObject:layer];
		[self.contentContainerLayer addSublayer:layer];

		// Now move to the new layer
		int index = [pagesLayers count]-1;

		[[UIApplication sharedApplication] beginIgnoringInteractionEvents];

		[CATransaction begin];

		// Set duration
		int times = abs(self.selectedIndex - index);
		float duration = 0.2 * times;
		[CATransaction setAnimationDuration:duration];

		// Timing function
		CAMediaTimingFunction *easeInOut = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
		[CATransaction setValue:easeInOut forKey:kCATransactionAnimationTimingFunction];

		// Callback
		[CATransaction setCompletionBlock:^{
			[CATransaction begin];
			[CATransaction setAnimationDuration:0.3];

			[CATransaction setCompletionBlock:^{
				[[UIApplication sharedApplication] endIgnoringInteractionEvents];
				[self zoomCurrentLayerForEnding];
			}];

			// Show currentLayer
			self.currentLayer.position = [self positionForCurrentLayer];
			self.currentLayer.opacity = 1.0;

			// Show closeButton
			if (self.shouldShowCloseButton)
			{
				[self.currentLayer sublayerWithName:@"closeButton"].opacity = 1.0f;
			}

			// Update dotLayer and nameLayer
			self.nameLayer.string = self.currentLayer.name;
			self.dotLayer.activeDot = self.selectedIndex;
			self.dotLayer.numberOfDots = [pagesLayers count];

			[CATransaction commit];
		}];

		// Change selectedIndex
		self.selectedIndex = index;

		// Actually move the layers
		for (int i = 0; i < [pagesLayers count]; i++)
		{
			CALayer *layer = [pagesLayers objectAtIndex:i];

			// Animate position
			layer.position = [self positionForLayerAtIndex:i];

			// Animate opacity + closeButton's opacity
			if (i != self.selectedIndex)
			{
				layer.opacity = 0.4;
				[layer sublayerWithName:@"closeButton"].opacity = 0.0;
			}
		}

		[CATransaction commit];
	}

The bold lines show the completion block (encapsulated inside ^{ /* Code goes here... */ }) that will be executed right after the animation is finished. One special thing about block is that it inherits all the local variables declared prior to its declaration - although a block can only access a read-only representation of the variables. If you want a variable to be fully accessible by a block, you need to add the __block directive when declaring the variable (ex. __block int index).

Now LSPageViewController can respond to orientation change as well! If you use another view controller to manage LSPageViewController, you will need to forward - (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration to the instance of LSPageViewController (again, look at the implementation of MainViewController in the example project). On the other hand, if LSPageViewController's view is added as a subview to the main window, there's no need to forward the message. However, in both cases, you need to configure the autoresizing mask of LSPageViewController's view in order for it to resize correctly when the orientation changes - this configuration can be done both in Interface Builder or programmatically.

To be more specific, when the orientation changes, LSPageViewController receives a message (either forwarded from your own view controller or automatically from the main window; the name of the message is - (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration - obviously.) LSPageViewController then proceeds to resize the views of the view controllers that it manages, and if the presentation view is being shown, resize it and notifies it of the orientation change so it can resize and reposition its layers (in LSPagesPresentationView's -(void)respondtoOrientationChange:).

In summary, this section showed you how LSPageViewController passes layers to the instance of LSPagesPresentationView, how the layers are set up and animated into position after being passed to LSPagesPresentationView, how gesture recognizers are set up and how implicit animations are used in the project.

Now that you have understood the underpinnings of LSPageViewController and LSPagesPresentationView, you can easily customize those two to fit with your requirements.

Make it yours

You want more labels? No labels? Change background? No worries! You can customize LSPageViewController and LSPagesPresentationView as much as possible, until they feel just right for you.

There isn't much need to change LSPageViewController, because it only serves as the middle man between your view controller and LSPagesPresentationView - storing the supplied UIViewControllers and passing them to LSPagesPresentationView when - [LSPageViewController displayPagesPresentationView] is called. However, as mentioned above, LSPageViewController does not pass raw view controllers - but processed CALayers - to LSPagesPresentationView, if you need to customize those layers, read and change - (CALayer *)layerForViewController:(UIViewController *)vc to suit your needs.

There are a lot more options for customizing LSPagesPresentationView - this is where the magic happens.

To customize the background gradient (change the colors or draw a completely different one), modify - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx in the Delegate category.

For customization regarding size and position of layers, refer to the category Maths in LSPagesPresentationView.m. Read LSPagesPresentationViewPrivate.m to see a list of Maths method. Be aware that many Maths methods are dependent on other methods so modifying one can affect them all. In general, study all Maths method before customizing.

To customize name layers, refer to - (void)setupNameLayer. From here, you can add more or delete all of them. If you change this method, also check these methods:

  • - (void)prepareForEntranceAnimation
  • - (void)handleSingleTap
  • - (void)handleSwipeLeft
  • - (void)handleSwipeRight
  • - (void)touchesBegan:forEvent:
  • - (void)touchesMoved:forEvent:
  • - (void)touchesEnded:forEvent:
  • - (void)touchesCancelled:forEvent:
  • - (void)addNewLayer:
  • - (void)insertLayer:atIndex:
  • - (void)removeLayerAtIndex:

The methods above contain animations that will move the view-representing layers and also update the dot and name layers to match currentLayer. You need to change these methods to accommodate the addition or removal of name layers.

To customize animations, check the aforementioned list of methods. LSPagesPresentationView no longer has a dedicated Animation category, because I found that solution to be too inflexible - there are small tweaks needed for each animation. If you want to be absolutely sure, just search for "[CATransaction begin]" in the .m file. Each [CATransation begin/commit] group represents a group of implicit animations.

In this new version of LSPagesPresentationView, there is also no longer a - (void)processTouch:. Gesture recognizers now handle the work for us. There is still an NSMutableDictionary extraTouchInfo but that is only used to store information to be used among the EventHandling methods - it is insignificant compared to the role of touchStorage in the previous version (for 3.1.3 and lower). If you want to modify the gesture recognition algorithm, make subclasses of UIGestureRecognizer and replace the 3 stock recognizers I used in - (id)initWithFrame:layers:. You can consult Apple's documentation about subclassing notes for UIGestureRecognizer.

That covered pretty much everything. Feel free to shout out if you have any questions or concern.

Thanks & Acknowledgements

  • Scott Stevenson. His ArtGallery project was a great help. Although I didn't directly use any piece of code from the project, it gave me an idea on how to implement this.
  • Alan Duncun and his example on drawing a circle.
  • Anybody kind enough to test this project on their own devices, as I cannot confirm the performance of the library solely with the iPhone Simulator.
  • And many more important figures whom I have forgotten.

To do

  • Currently satisfied (again). Let me know if you want something improved or included.
  • Still need to test this on a real device though :(

History

  • 12 July 2010: Now supports orientation changes! Requires the user of this library to comply to some requirements though - read the sections above. 
  • 30 June 2010: Removed two swipe gesture recognizers and made slight changes to animation behaviors - there is a 'friction' effect if you try to move a leftmost layer to the right, or the rightmost layer to the left, where there is no more layer to display.
  • 27 June 2010: Added - [LSPageViewController insertViewController:atIndex:] for those who might need it. Also, the NSMutableArray that LSPageViewController uses to store managed view controllers is now exposed through the read-only property viewControllers (read-only means that you can't assign a new array to that property - you can still add and remove objects at will).
  • 25 June 2010: Support for iOS 4.
    • UIGestureRecognizer subclasses are used instead of manual gesture recognition, hence the removal of touchStorage and - (void)processTouch:.
    • When chaining animations, callbacks are no longer used. Instead, blocks are used by assigning them with + [CATransaction setCompletionBlock:]. The assigned blocks will then be executed when all animations inside a CATransaction group have finished. The codebase is greatly simplified and easier to read.
    • Shadows can be enabled, but I'm not sure whether there is any performance hit. (If anybody is able to test this on their own device, sound off in the comments)
  • 20 June 2010: Minor update - bigger close button.
  • 14 June 2010: First release!

Ugh, maintenance is twice as hard and time consuming, isn't it?

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Tom The Cat
Other Hanoi - Amsterdam High School
Vietnam Vietnam
I'm a 16 year old high school student. I know some about Objective-C and a bit about C#, mostly through reading stuff online.
Follow on   Twitter

Comments and Discussions

 
GeneralRe: My vote of 3 PinmemberTom The Cat19-Jul-10 7:07 
GeneralMy vote of 1 PinmembercTothop14-Jul-10 3:49 
Generalvoting reason? PinmemberJoel Ivory Johnson18-Jul-10 13:14 
GeneralRe: My vote of 1 PinmemberTom The Cat18-Jul-10 21:17 
GeneralIs this vote authentic? Pinmembernawlins19-Jul-10 8:50 
GeneralMy vote of 5 Pinmemberm88m12-Jul-10 4:58 
GeneralRe: My vote of 5 PinmemberTom The Cat12-Jul-10 8:02 
GeneralRe: My vote of 5 Pinmemberm88m12-Jul-10 9:08 
GeneralMy vote of 5 Pinmembercylon6-Jul-10 23:27 
GeneralRe: My vote of 5 PinmemberTom The Cat7-Jul-10 8:02 
GeneralGreat Post Pinmemberdenom229-Jun-10 13:02 
GeneralRe: Great Post PinmemberTom The Cat29-Jun-10 15:55 
Joke^^ Pinmemberarimayukino16-Jun-10 4:07 
GeneralRe: ^^ PinmemberTom The Cat16-Jun-10 6:26 
GeneralRe: ^^ PinmemberJoel Ivory Johnson14-Jul-10 10:05 
GeneralRe: ^^ PinmemberTom The Cat14-Jul-10 12:23 
GeneralLooks great PinmemberQuynh Huong Trinh14-Jun-10 8:32 
GeneralRe: Looks great Pinmembertomthecat9414-Jun-10 15:58 
GeneralThis is nice. Pinmemberuncreativeboi14-Jun-10 6:22 
GeneralRe: This is nice. Pinmembertomthecat9414-Jun-10 6:27 
GeneralRe: This is nice. PinmemberTuPacMansur16-Jun-10 16:09 
GeneralRe: This is nice. PinmemberTom The Cat16-Jun-10 17:15 
GeneralNice PinmemberNiklas Lindquist14-Jun-10 5:55 
GeneralRe: Nice Pinmembertomthecat9414-Jun-10 5:57 
GeneralA big thanks to Sacha Barber Pinmembertomthecat9414-Jun-10 4:35 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.141030.1 | Last Updated 23 Jul 2010
Article Copyright 2010 by Tom The Cat
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid