UITextViewのリンク認識と編集可能を両立してみる

UITextViewは、テキスト中のURLや日付、住所なんかを自動認識してリンクを付けてくれる機能がついています。

ただし、この機能は編集可能なUITextViewではオンにすることが出来ません。もし編集可能なUITextViewでもこの機能をオンにしたいと思ったら、普段は編集不可、自動認識をオンにしておき、何かのきっかけで編集可能、自動認識オフに切り替えなければなりません。

標準のメモアプリを見ると、テキスト入力エリアをタップするとリンクは消えて編集モードになるので、そういう挙動にしたいと思いました。コードはこんな感じ。

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
	if (!self.editable) {
		tapped = YES;
	}
	[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
	tapped = NO;
	[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
	[super touchesEnded:touches withEvent:event];
	if (tapped) {
		self.dataDetectorTypes = UIDataDetectorTypeNone;
		self.editable = YES;
		[self becomeFirstResponder];
	}
}

タップでなくスクロールだった時はフラグで無効にしてます。
この方法の問題点は、タップした場所と関係なく、編集に入った時のカーソルの位置がテキストの先頭に来てしまうというところです。当たり前ですが。でも標準のメモ帳アプリはちゃんと、タップしたところにカーソルが来ます。さてどうしたらその挙動になるのでしょう?

実際のタップでまず編集可能にしてから、コードでもう一回タップをシミュレートしてやる。
ダメでした。タップイベントを作り出す方法が無かったのです。
実際のタップのイベントを保持しておき、編集可能にしてから再送する
ダメでした。機能しません。
UITextViewのサブビューにはおそらくUITextInputプロトコルに適合したビューがあるはず。これを叩く
やったのはこれです。

UITextInput Protocol Reference
http://developer.apple.com/library/ios/#documentation/uikit/reference/UITextInput_Protocol/Reference/Reference.html

そもそもUITextInputとは何ぞやというと、自分でUITextViewみたいなテキスト入力可能なViewを作るときに適合すべきプロトコルです。文字列中の位置とView上の位置を対応させるためのメソッドたちが含まれています。
例えばclosestPositionToPoint:でタップされた座標に最寄りの文字列位置を聞いて、selectedTextRangeにセットしてやればそこにカーソルが移動できそうじゃないですか!
…実際やって見ると出来ませんでした。closestPositionToPoint:が正しい値を返してくれなかったので。

しかし今回はそこで諦めませんでしたよ。UITextInputの他のメソッドを使えば、「文字列位置からView上の位置を割り出す」ことは出来ます。だから、一文字ずつ位置を調べてってタップされた座標に近いところを割り出せばいいじゃないですか。
…えらく非効率なやり方ですけど、やってみました。さっきの置き換え。

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
	[super touchesEnded:touches withEvent:event];
	if (tapped) {
		
		self.dataDetectorTypes = UIDataDetectorTypeNone;
		self.editable = YES;
		[self becomeFirstResponder];
		
		UITouch *aTouch = [touches anyObject];
		
		NSArray *textSubViews = self.subviews;
		for (UIView *subView in textSubViews) {
			if ([subView conformsToProtocol:@protocol(UITextInput)]) {
				UIView <UITextInput> *textInputView = (UIView <UITextInput> *)subView;
				CGPoint point = [aTouch locationInView:textInputView];
				UITextPosition *beginningPosition = textInputView.beginningOfDocument;
				UITextPosition *endPosition = textInputView.endOfDocument;
				UITextPosition *currentPosition = beginningPosition;
				UITextPosition *closestPosition = currentPosition;
				
				CGFloat minXd = CGFLOAT_MAX;
				CGFloat minYd = CGFLOAT_MAX;
				CGFloat maxYd = -1;
				CGFloat lineHeight = self.font.lineHeight;
				do {
					CGRect caretRect = [textInputView caretRectForPosition:currentPosition];
					CGPoint caretPoint = CGPointMake(caretRect.origin.x+caretRect.size.width/2, caretRect.origin.y+caretRect.size.height/2);
					CGFloat yd = fabs(caretPoint.y - point.y);
					CGFloat xd = fabs(caretPoint.x - point.x);
					
					if (maxYd < 0) maxYd = yd;
					
					if (yd > maxYd) { // blank line
						
						if (lineHeight/2 > minYd) break;
						yd = fabs(minYd - lineHeight);
					}
					if (yd < minYd) { // closest line
						closestPosition = currentPosition;
						minYd = yd;
						minXd = CGFLOAT_MAX;
					} else if (yd > minYd){ // next of closest line
						break;
					}
					if (yd == minYd) {
						if (xd < minXd) {
							closestPosition = currentPosition;
							minXd = xd;
						}
					}
				} while (currentPosition = [textInputView positionFromPosition:currentPosition offset:1]);
				UITextRange *textRange = [textInputView textRangeFromPosition:closestPosition toPosition:closestPosition];
				if (textRange && minYd < lineHeight) {
					textInputView.selectedTextRange = textRange;
				} else {
					UITextRange *endRange = [textInputView textRangeFromPosition:endPosition toPosition:endPosition];
					textInputView.selectedTextRange = endRange;
				}
				return;
			}
		}
	}
}

やたら汚いのにはもう一つ理由があります。このコードは文字列位置に対してカーソルの位置を返すcaretRectForPosition:を使っていちいち調べてるんですが、これが空白行だと正しい値が帰ってきません。一定の変な値が帰ってくるので、それで区別しています。

ここまでやって期待通り動くようになりましたが、将来のiOSでどうなるか、そして審査に通るのか(privateなAPIは使っていませんが)は分かりません。

方法としてはもう一つ、

UIStringDrawingなんかのメソッドを使って自分でレイアウトを計算し、UITextViewのselectedRangeを設定するというのがありますね。
…そっちのほうが良かったんじゃない?