Between bottle feedings and playing with the kids I’ve been working on an iPhone RTF parser as well as a UITextInput library. I’m intending to write a few “how-to” posts to explain how these classes work but for the moment I’ve posted my work on the UITextInput library and abandoned the RTF parser. I was prompted to do this now because the wonderful OmniGroup has posted an update to their public framework that includes a bunch of awesome work on RTF, UITextInput and much much more.
They have a “fully working” TextEditor example with document management and style input right of the box (MIT licensed too!). One note however, if you’re going to try out the framework be sure to follow the readme:
To checkout:
git clone git://github.com/omnigroup/OmniGroup
To build Debug versions of all the Omni frameworks:
cd OmniGroup
./Scripts/Build Frameworks
To build Release versions of all the Omni frameworks, instead do:
./Scripts/Build Frameworks install
You’ll also need to edit your XCode build pref pane, and set “Place Build Products in:” to a Customized Location: /Users/Shared/[yourusername]/Products. That’s where the frameworks are built to and if this isn’t set you’ll be looking at a lot of error messages.
The iPad TextEditor example is in OmniGroup/Frameworks/OmniUI/iPad/Examples/TextEditor and requires the 3.2 SDK to build it (4.0 beta throws some errors when you try to run the app).

Out of the box it has a nice document manager and full editing but there’s lots of rough edges and areas for improvement. I’m going to fork it and implement some of my UITextInput interactions to get it looking a little spiffier so follow me on Github or Twitter if you’re interested.
As a follow up to yesterday’s post about “Dissecting iPhone OS Touch Actions for Text” here’s a rough outline of the event flow as it would apply to a UITextInput as a child of a UIScrollView. This simply does an NSLog of the various actions that take place as you interact and tap around. For example a single tap logs:
2010-05-02 23:44:05.141 CoreTextEditor[91771:207] #1 end
2010-05-02 23:44:05.143 CoreTextEditor[91771:207] - Close menus
2010-05-02 23:44:05.144 CoreTextEditor[91771:207] - Clear selection & place carat
2010-05-02 23:44:05.144 CoreTextEditor[91771:207] - Enable Scrolling
(gdb)
but a double tap logs:
2010-05-02 23:45:25.965 CoreTextEditor[91771:207] #1 end
2010-05-02 23:45:25.966 CoreTextEditor[91771:207] - Close menus
2010-05-02 23:45:25.967 CoreTextEditor[91771:207] - Clear selection & place carat
2010-05-02 23:45:25.967 CoreTextEditor[91771:207] - Enable Scrolling
2010-05-02 23:45:26.079 CoreTextEditor[91771:207] - Disable Scrolling
2010-05-02 23:45:26.080 CoreTextEditor[91771:207] #5 start
2010-05-02 23:45:26.082 CoreTextEditor[91771:207] - Disable scrolling
2010-05-02 23:45:26.083 CoreTextEditor[91771:207] - Highlight word
2010-05-02 23:45:26.083 CoreTextEditor[91771:207] #3 or #6 end
2010-05-02 23:45:26.084 CoreTextEditor[91771:207] - Close loop
2010-05-02 23:45:26.085 CoreTextEditor[91771:207] - Show menu for appropriate selection
2010-05-02 23:45:26.086 CoreTextEditor[91771:207] - Enable Scrolling
(gdb)
It is far from complete and is only a rough guide. I also haven’t implemented all this yet so there’s probably a few glitches. Once I have it fully implemented I’ll post a working project example:
Header:
//
// EditorScrollView.h
// CoreTextEditor
//
// Created by Jeffrey Sambells on 10-05-02.
// Copyright 2010 TropicalPixels. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface EditorView : UIScrollView {
bool _dragEditingActive;
bool _hasMoved;
}
@end
Class:
//
// EditorScrollView.m
// CoreTextEditor
//
// Created by Jeffrey Sambells on 10-05-02.
// Copyright 2010 TropicalPixels. All rights reserved.
//
#import "EditorView.h"
#import "EditorDocumentView.h"
@implementation EditorView
- (id)initWithFrame:(CGRect)frame {
if ((self = [super initWithFrame:frame])) {
self.autoresizesSubviews = NO;
//self.delegate = self;
self.userInteractionEnabled = YES;
self.minimumZoomScale = 1.0f;
self.maximumZoomScale = 1.0f;
self.scrollEnabled = YES;
self.bounces = YES;
self.bouncesZoom = NO;
self.contentSize = CGSizeMake(frame.size.width, frame.size.height * 2); // will auto rezies baed on content.
self.scrollsToTop = YES;
self.autoresizingMask = (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight);
_dragEditingActive = NO;
}
return self;
}
- (void)dealloc {
[super dealloc];
}
#pragma mark -
#pragma mark Touch Events
/*
Observed touch interactions in iPhone OS 3.2 on text editng fields:
1. Single quick tap without a drag, on release:
- Close any menus.
- Clear any selections.
- Place carat before or after the word depending on tap proximity in UITextInputTokenizer.
2. Single quick tap without a drag repeated in same location after a "long"
pause (not a double tap, basically another tap in the same location later):
- If the calculated carat placement is the same then open the menu.
3. Long single tap:
- Disable scrolling.
- Close any menus.
- Clear any selections.
- Show the carat placement loop.
- Place the carat at the tap location.
- On end show the menu.
4. Single tap and drag:
- Move the scroll view, release does nothing.
5. Double tap (with release):
- Highlight the word.
- Show cut/copy/paste/replace menu.
6. Double tap with hold/drag:
- Highlight word.
- Disable scrolling.
- Show selection loop (square) with short delay that could be cancelled by the end touch.
- Resulting selection must include full word as either first or last in selection.
- On release show cut/copy/paste/replace menu.
7. Triple (or more) tap
- Cancel everything and results in #1.
*/
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
// Set movement flag.
_hasMoved = NO;
UITouch *touch = [touches anyObject];
if ([touch tapCount] == 1) {
// Show the loop in 0.45 sec.
[self performSelector:@selector(_startSingleTapDelayEditingAction:) withObject:touches afterDelay: 0.45];
} else if([touch tapCount] == 2) {
// Cancel the delayed action if it hasn't fired.
[EditorView cancelPreviousPerformRequestsWithTarget:self selector:@selector(_startSingleTapDelayEditingAction:) object:touches];
// Double Tapping immediately cancells scrolling.
NSLog(@"- Disable Scrolling");
[self setScrollEnabled:NO];
// Start the action immediately.
[self _startDoubleTapEditingAction:touches];
}
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
{
// Set movement flag.
_hasMoved = YES;
// Cancel the delayed action if it hasn't fired.
[EditorView cancelPreviousPerformRequestsWithTarget:self selector:@selector(_startSingleTapDelayEditingAction:) object:touches];
// If editing is active then do the move actions.
if ( _dragEditingActive ) {
[self _moveEditingAction:touches];
}
[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
// Cancel the delayed action if it hasn't fired.
[EditorView cancelPreviousPerformRequestsWithTarget:self selector:@selector(_startSingleTapDelayEditingAction:) object:touches];
// If we're in editing mode or we haven't moved
if (_dragEditingActive || !_hasMoved) {
[self _endEditingAction:touches];
}
// Reset moved flag.
_hasMoved = NO;
[super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
{
[super touchesCancelled:touches withEvent:event];
}
#pragma mark Touch Results
- (void)_startSingleTapDelayEditingAction:(NSSet *)touches;
{
NSLog(@"#3 start");
NSLog(@"- Disable Scrolling");
[self setScrollEnabled:NO];
_dragEditingActive = YES;
NSLog(@"- Close any menus");
NSLog(@"- Clear selections and place carat");
}
- (void)_startDoubleTapEditingAction:(NSSet *)touches;
{
NSLog(@"#5 start");
NSLog(@"- Disable scrolling");
[self setScrollEnabled:NO];
_dragEditingActive = YES;
NSLog(@"- Highlight word");
[self performSelector:@selector(_startDoubleTapDelayEditingAction:) withObject:touches afterDelay: 0.15];
}
- (void)_startDoubleTapDelayEditingAction:(NSSet *)touches;
{
NSLog(@"- Show selection loop (square) after a short delay");
}
- (void)_moveEditingAction:(NSSet *)touches;
{
UITouch *touch = [touches anyObject];
if ([touch tapCount] == 1) {
NSLog(@"#3 move");
NSLog(@"- Place carat at the tap location");
NSLog(@"Show carat placement loop");
} else if([touch tapCount] == 2) {
NSLog(@"#6 move");
NSLog(@"- Select must include full word as either first or last in selection");
}
}
- (void)_endEditingAction:(NSSet *)touches;
{
UITouch *touch = [touches anyObject];
if (!_dragEditingActive) {
NSLog(@"#1 end");
NSLog(@"- Close menus");
NSLog(@"- Clear selection & place carat");
} else {
[EditorView cancelPreviousPerformRequestsWithTarget:self selector:@selector(_startDoubleTapDelayEditingAction:) object:touches];
NSLog(@"#3 or #6 end");
NSLog(@"- Close loops");
NSLog(@"- Show menu for appropriate selection");
}
// reset flags;
NSLog(@"- Enable Scrolling");
[self setScrollEnabled:YES];
_dragEditingActive = NO;
}
@end
I’ve been working my way through a UiTextInput rich text editor implementation in iPhone OS 3.2 and thought I’d share some observations I’ve made regarding touch input related to text.
One of the biggest hurdles with UiTextInput is that you don’t get the benefit of the great UI work that Apple has put in their core iPhone text inputs. There’s no text selection, magnification loops, copy/paste or any of those other things we take for granted when we use the default inputs. If you want advanced editing features you have to implement them all yourself—and it’s a lot harder than it looks.
Text selection, for example, is something that you’re probably familiar with if you’ve used an iPhone or iPad but have you ever stopped to actually look what’s going on? The subtleties of the touch interaction are very intuitive but may not be what you expect when you think about it. For example, here are few things you may not have noticed when editing text in something like the default notepad.
When you quickly single tap, the carat is placed in the text where you tapped but it’s position is based on word boundaries. A mouse click in a word processor places the carat at the point where you clicked but a tap in iPhone OS places it in front of or after the word, depending on which end of the word was nearest the tap.
Holding on a word will reveal a magnifying loop that gives you fine grained control over the selection. This is the only way you can place the carat within a word.
The select/select all/paste menu will appear if you long-tap. As a result it will always appear after the loop is shown but could also appear if you touch and release just before the loop shows (of course you’ll be selecting and pasting on a word boundary in the latter case).
The “select” option in the menu always starts by selecting a full word. Which word depends on where the carat is placed. If the carat is in a word then that word will be selected. If the carat is at the beginning of a word, between the space and the first letter, then the word following the carat is selected. If the carat is at the end of a word, before the following space, then the word preceding the carat is selected.
Double tapping a word will immediately select it. If you hold and drag on the second tap you can select a larger range based on where you drag. Interestingly, the range selection also has a magnifying loop—but with a different shape—so you can have fine grained control at the end of the selection.
These are just a few of the subtle interactions that most iPhone users just do without thinking about. If you’re going to implement your own UITextInput be sure to closely look at the existing text inputs and how you typically interact with them as you’ll need to implement yours the way users will expect it to work.