こんにちは。iOSエンジニアの庄司(@WorldDownTown)です。 最近のIQONのアップデートで、コーデのタグ表示のUIを変更しました。 この変更では、ユーザーがテキストで無制限に埋め込んだタグを選択できるようになりました。 例えば「#スニーカー」をタップすると、「スニーカー」タグが付いたコーデが表示されます。
他のアプリでも見かけるUIなので、簡単にUITextViewで実装できるかと思ってたのですが… 思いの外ハマったので、今回の実装を共有します。 UITextViewのサブクラスを作成して、上記の動きを実現します。
実装
1. UIGestureRecognizerを継承したクラスを作る
IQONのコーデのタグのような使い方だと、リンク文字列が多いためタッチイベントを奪ってしまい、スクロールに失敗することが多くなります。
NSAttributedString の NSLinkAttributeName の機能では上記の問題が発生するため、独自のジェスチャー (TouchUpDownGestureRecognizer) を実装しました。 このGestureRecognizerを次に説明するUITextViewのサブクラスに適用します。 このジェスチャーがUITextViewに対するタッチイベントの、開始、終了、移動、キャンセルの状態を決めます。
1-1. 親Viewのジェスチャーとの衝突を回避する
スクロールを阻害しないようにするには UIGestureRecognizerSubclass の下記のメソッドをオーバーライドします。
func canPreventGestureRecognizer(preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
if let _ = preventedGestureRecognizer.view as? UIScrollView { return false }
return true
}
2. UITextViewを継承したクラスを作る
自作したジェスチャを受け取るUITextViewのサブクラスのLinkTextViewを作ります。
2-1. ジェスチャーの設定
LinkTextViewのイニシャライザでジェスチャーを設定します。
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
...
let recognizer = TouchUpDownGestureRecognizer(target: self, action: "handleTouchUpDownGesture:") addGestureRecognizer(recognizer)
}
func handleTouchUpDownGesture(recognizer: TouchUpDownGestureRecognizer) {
switch recognizer.state {
case .Began: touchDownWithRecognizer(recognizer)
case .Changed: break case .Ended: touchUpWithRecognizer(recognizer)
default: touchCancelWithRecognizer(recognizer)
}
}
2-2. タップした文字列を判別する
下記のメソッドでタッチした位置のリンク文字列の位置と長さをNSRangeで受け取ります。
private func selectedLinkRangeWithRecognizer(recognizer: TouchUpDownGestureRecognizer) -> NSRange? {
var location = recognizer.locationInView(self) location.y -= textContainerInset.top
location.x -= textContainerInset.left let characterIndex = layoutManager.characterIndexForPoint(location, inTextContainer: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
var range = NSMakeRange(0, 0) let object = attributedText.attribute( LinkTextView.LinkKey, atIndex: characterIndex, effectiveRange: &range)
return (object == nil) ? nil : range
}
ハマったこと
3D Touch 対応端末で動かなかった
iPhone 6s を購入して動作を検証すると、リンクが動きませんでした。 Xcode7のiPhoen 6sシミュレータでは再現しないため、かなり焦りましたが、原因は3D Touchの仕組みによるものでした。 UIGestureRecogniezrを継承したクラスでは、タッチの強さ (touch.force) の変化でも touchesMoved:withEvent: が呼ばれてしまうようです。
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesMoved(touches, withEvent: event)
state = .Cancelled
}
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesMoved(touches, withEvent: event)
if let touch = touches.first {
let beforePoint = touch.previousLocationInView(view)
let afterPoint = touch.locationInView(view)
if !CGPointEqualToPoint(beforePoint, afterPoint) {
state = .Cancelled
}
}
できあがったもの
こんな感じで動きます。
使い方
LinkTextView の attributedString にリンク文字列を NSAttributedString で渡します。 この時、リンクを識別するために LinkTextView.LinkKey という属性を含めます。 タップ時の処理はクロージャプロパティに設定し、リンク文字列をタップした時のみ、このクロージャが呼び出されます。
let attributes = [
NSForegroundColorAttributeName: UIColor.blueColor(),
LinkTextView.LinkKey: "linked",
mutableAttributedString.addAttributes(attributes, range: NSRange(0, 5))
]
let textView = LinkTextView() textView.attributedText = mutableAttributedString
textView.linkClickedBlock = { (string: String) in
}
まとめ
文章の中の任意の文字列をリンクにする方法を紹介しました。 今回の方法を使えば、UIScrollViewにaddSubviewしてもスクロールイベントを妨害せずにリンクを作る事ができます。 3D Touch端末の問題をクリアしていますが、逆に3D TouchのPeekを実装するためには、別の実装にするのが良いと思います。 細かいところはいくつか省きましたが、そのまま動くサンプルプロジェクトを用意したので、動かしながら確認してみてください。 https://github.com/WorldDownTown/LinkTextView