UITextViewに矢印キー（カーソル移動）ボタンを実装する

[update: 2015/11/02]

6Sで追加されたForceTouchが使いやすいので自分は以下のコードは捨ててしまいましたが、備忘録として残しておきます。

___

テキストエディタ類を実装する際、ユーザーテストと称してUITextView内で入力作業を長く続けてみると、カーソル位置をタップ→ドラッグ操作で移動させるのが地味にツラいと感じた。

物理キーボードのように矢印キーで操作したい。
しかしiOSには文字以外のキー入力操作コマンドを送信するAPIがない。[^1]

そこでinputAccessoryViewに上下左右の矢印キーボタンを追加してみた。

以下でUITextView内のキャレット位置を計算して移動させる実装を行っている。

* 左右はUITextPositionを1文字前後させればOK。
* 上下で若干悩んだが、キャレット位置での座標とUIFontを取得し、lineheightプロパティ分だけ垂直方向に移動させた座標から`closestPositionToPoint:`でUITextPositionを得る。

```objc:UIViewController.m
- (void)viewDidLoad
{
    [super viewDidLoad];
    //...
    [self setUpCursorKeyboard];
}

- (void)setUpCursorKeyboard
{
    UIToolbar *toolBar = [[UIToolbar alloc] initWithFrame:CGRectMake(0, 0, 320, 44)];
    
    UIBarButtonItem *left = [[UIBarButtonItem alloc] initWithTitle:@" ← "
                                                             style:UIBarButtonItemStylePlain
                                                            target:self
                                                            action:@selector(simulateLeftKey)];
    UIBarButtonItem *right = [[UIBarButtonItem alloc] initWithTitle:@" → "
                                                              style:UIBarButtonItemStylePlain
                                                             target:self
                                                             action:@selector(simulateRightKey)];
    UIBarButtonItem *down = [[UIBarButtonItem alloc] initWithTitle:@" ↓ "
                                                             style:UIBarButtonItemStylePlain
                                                            target:self
                                                            action:@selector(simulateDownKey)];
    UIBarButtonItem *up = [[UIBarButtonItem alloc] initWithTitle:@" ↑ "
                                                           style:UIBarButtonItemStylePlain
                                                          target:self
                                                          action:@selector(simulateUpKey)];
    
    [toolBar setItems:@[left, up, down, right] animated:YES];
    
    self.textView.inputAccessoryView = toolBar;
}

- (void)simulateLeftKey
{
    UITextRange *currentRange = self.textView.selectedTextRange;
    
    if (![currentRange.start isEqual:self.textView.beginningOfDocument]) {
        UITextPosition *newPosition = [self.textView positionFromPosition:currentRange.start
                                                                   offset:-1];
        self.textView.selectedTextRange = [self.textView textRangeFromPosition:newPosition
                                                                    toPosition:newPosition];
    }
}

- (void)simulateRightKey
{
    UITextRange *currentRange = self.textView.selectedTextRange;
    
    if (![currentRange.end isEqual:self.textView.endOfDocument]) {
        UITextPosition *newPosition = [self.textView positionFromPosition:currentRange.end
                                                                   offset:1];
        self.textView.selectedTextRange = [self.textView textRangeFromPosition:newPosition
                                                                    toPosition:newPosition];
    }
}

- (void)simulateUpKey
{
    UITextRange *currentRange = self.textView.selectedTextRange;
    UITextRange *allRange = [self.textView textRangeFromPosition:self.textView.beginningOfDocument
                                                      toPosition:self.textView.endOfDocument];

    CGRect caretRect = [self.textView caretRectForPosition:currentRange.start];
    
    NSDictionary *att = [self.textView textStylingAtPosition:currentRange.start
                                                 inDirection:UITextStorageDirectionForward];
    UIFont *currentFont = att[NSFontAttributeName];
    CGFloat nextY = caretRect.origin.y + (caretRect.size.height/2) - currentFont.lineHeight;
    CGPoint nextCaretPoint = CGPointMake(caretRect.origin.x, nextY);
    UITextPosition *newPosition = [self.textView closestPositionToPoint:nextCaretPoint
                                                            withinRange:allRange];
    
    self.textView.selectedTextRange = [self.textView textRangeFromPosition:newPosition
                                                                toPosition:newPosition];
}

- (void)simulateDownKey
{
    UITextRange *currentRange = self.textView.selectedTextRange;
    UITextRange *allRange = [self.textView textRangeFromPosition:self.textView.beginningOfDocument
                                                      toPosition:self.textView.endOfDocument];

    CGRect caretRect = [self.textView caretRectForPosition:currentRange.end];
    
    NSDictionary *att = [self.textView textStylingAtPosition:currentRange.end
                                                 inDirection:UITextStorageDirectionForward];
    UIFont *currentFont = att[NSFontAttributeName];
    CGFloat nextY = caretRect.origin.y + (caretRect.size.height/2) + currentFont.lineHeight;
    CGPoint nextCaretPoint = CGPointMake(caretRect.origin.x, nextY);
    UITextPosition *newPosition = [self.textView closestPositionToPoint:nextCaretPoint
                                                            withinRange:allRange];
    
    self.textView.selectedTextRange = [self.textView textRangeFromPosition:newPosition
                                                                toPosition:newPosition];
}
```

キャレットが範囲選択状態のときの挙動は人によって好みがあると思うので
`if (!self.textView.selectedTextRange.empty) {}` の条件分岐を設けるなどして別処理にすると良いと思います。

[^1]: OSXはNSEventでキー入力イベントを生成可能らしい