読者です 読者をやめる 読者になる 読者になる

24/7 twenty-four seven

iOS/OS X application programing topics.

UITextView でタップ可能なリンクをカスタマイズする

iPhone Objective-C

UITextView では dataDetectorTypes を設定することでデータタイプに応じて自動的にクリック可能なリンクとして表示してくれます。

例えば下記のようにしていすると、URLが含まれていた場合、タップ可能なリンクとして表示されます。

cell. tweetTextView. dataDetectorTypes = UIDataDetectorTypeLink;



他にも次のようなデータタイプが用意されていて、電話番号、住所、イベント(日付や「今週」「今夜」など)っぽい文字列をリンクにすることができます。

typedef NS_OPTIONS(NSUInteger, UIDataDetectorTypes) {
    UIDataDetectorTypePhoneNumber   = 1 << 0,          // Phone number detection
    UIDataDetectorTypeLink          = 1 << 1,          // URL detection    
#if __IPHONE_4_0 <= __IPHONE_OS_VERSION_MAX_ALLOWED
    UIDataDetectorTypeAddress       = 1 << 2,          // Street address detection
    UIDataDetectorTypeCalendarEvent = 1 << 3,          // Event detection
#endif    

    UIDataDetectorTypeNone          = 0,               // No detection at all
    UIDataDetectorTypeAll           = NSUIntegerMax    // All types
};

ただ、任意の文字列をリンクにすることができなかったり、リンクをタップした時の処理があらかじめ決まったものに固定されている(Safariを開く、電話をかける、カレンダーに予定を登録する、など)など、実際に使ううえではかなり制限があります。

今回、こちらのリッチテキストビューライブラリ SECoreTextView を書くにあたって、たまたま使えそうなテクニックをいくつか発見したので紹介します。

ただし、それらのテクニックを使っても標準の UITextView で頑張れる限界はけっこうすぐ来るので、凝ったことをする場合には前述のライブラリなど別の手段で解決するのがいいと思います。

任意の文字列をタップ可能なリンクにする (iOS 6〜)

iOS 6 から UIKit のコンポーネントのいろいろなテキストに NSAttributedString が使えるようになりました。
UITextView にも attributedText というプロパティが追加され、スタイルを指定できるようになったのでそれを使います。


実は OS X には 10.0 の頃から NSLinkAttributeName という属性があります。
読んで字のごとく、文字列にリンク属性を付加します。


ただ NSLinkAttributeName はなぜか iOS には用意されていません。


しかし、たまたま発見したのですが NSLinkAttributeName 定数が宣言されていないだけで、NSLinkAttributeName が表す文字列を直接指定してみると iOS でも機能することがわかりました。


NSLinkAttributeName は文字列定数で @"NSLink" と定義されています。
そこで、次のように @"NSLink" + リンク文字列という形で属性を指定します。
↓ 下記の例は Twitter のツイートに含まれる @screen_name とハッシュタグをリンク属性として指定しています。

NSArray *textEentities = [TwitterText entitiesInText:text];
for (TwitterTextEntity *textEentity in textEentities) {
    if (textEentity.type == TwitterTextEntityScreenName) {
        NSString *screenName = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{@"NSLink": screenName}
                                  range:textEentity.range];
    } else if (textEentity.type == TwitterTextEntityHashtag) {
        NSString *hashTag = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{@"NSLink": hashTag}
                                  range:textEentity.range];
    }
}

cell.tweetTextView.attributedText = [[SETwitterHelper sharedInstance] attributedStringWithTweet:tweet];


↓ 実行結果は次のようになります。URLに加えてメンションやハッシュタグがリンクになっているのがわかるでしょうか。
各リンクはすべてタップ可能です。


ただし、これらのリンクのうち、@"NSLink" を指定して作ったリンクについてはタップしても Safari を開いたりはしてくれません。(開く URL も無いので当然ですが)
かろうじて長押しすると次のようなアクションシートが表示されて文字列のコピーができる、というような動作をします。
(Open は何も起こらない)


これでは実用にできませんので次にリンクの処理をカスタマイズする方法を紹介します。

リンクをタップしたときに任意の処理を実行する

Data Detector による自動リンク化ではあらかじめ決まった処理がシステムによって実行されるということは前に述べました。
また、@"NSLink" によるリンク化もそれだけでは実用的ではないことがわかりました。


そこで、既定の処理をフックすることでタップ時に任意の処理を実行するという方法を紹介します。

実は自動リンク化された URL のリンクをタップしたときは UIApplication の openURL: メソッドが呼ばれます。
つまり openURL: メソッドをオーバーライドすれば、そこで任意の処理を実行することができます。


次のように UIApplication のサブクラス Application を作成し、openURL: メソッドをオーバーライドします。
とりあえず、URLをログ出力するように変更します。

@interface Application : UIApplication

@end

@implementation Application

- (BOOL)openURL:(NSURL *)url
{
    NSLog(@"%@", url.absoluteString);
    return NO;
}

@end


アプリケーションクラスは UIApplicationMain 関数で指定されているので main.m を次のように変更します。

int main(int argc, char *argv[])
{
    @autoreleasepool {
        return UIApplicationMain(argc, argv, @"Application", NSStringFromClass([AppDelegate class]));
    }
}


これでアプリケーションクラスは UIApplication ではなく、Application が使われるようになりました。


ここまでで URL のリンクをタップしたときは カスタマイズしたメソッドが呼ばれるようになるのですが、実は @"NSLink" でリンク化したメンションやハッシュタグのリンクは openURL: メソッドが呼ばれません。


というのも @"NSLink" で単に文字列を指定した場合は自動的に applewebdata://[UUID]/[リンク文字列] のような URL としてリンク化されるので、applewebdata は開けるスキーマではないため、openURL: メソッドが呼ばれないのです。


ただし、ここまでわかっていれば問題は難しくありません。
要は開けるような URL にしてしまえばよいのです。


ということで @"NSLink" に対する値をちょっと変更して、適当なスキーマを付けてしまいます。
openURL: が呼ばれるもので、他にリンク文字列として使用されないものがいいでしょう。
ここではメンションに ftp を、ハッシュタグに maps を指定します。

NSArray *textEentities = [TwitterText entitiesInText:text];
for (TwitterTextEntity *textEentity in textEentities) {
    if (textEentity.type == TwitterTextEntityScreenName) {
        NSString *screenName = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{@"NSLink": [NSString stringWithFormat:@"ftp:%@", screenName]}
                                  range:textEentity.range];
    } else if (textEentity.type == TwitterTextEntityHashtag) {
        NSString *hashTag = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{@"NSLink": [NSString stringWithFormat:@"maps:%@", hashTag]}
                                  range:textEentity.range];
    }
}


これで、 メンションやハッシュタグのリンクをタップしたときも openURL: が呼ばれるようになり、スキーマによって何がタップされたのかも区別できるようになりました。

- (BOOL)openURL:(NSURL *)url
{
    NSString *scheme = url.scheme;
    if ([scheme hasPrefix:@"http"]) {
        // 通常のリンクの処理
        NSLog(@"%@", url.absoluteString);
    } else if ([scheme isEqualToString:@"ftp"]) {
        // メンションの処理
        NSLog(@"%@", url.absoluteString);
    } else if ([scheme hasPrefix:@"maps"]) {
        // ハッシュタグの処理
        NSLog(@"%@", url.absoluteString);
    }
    return NO;
}

リンクの書式を変更する (iOS 6〜)

せっかくいろいろなリンクを作れるようになったのですから、標準の青い文字色とアンダーラインでは物足りないですよね。
NSAttributedString はそもそもリッチテキストを表現するためのオブジェクトなので NSAttributedString でスタイルを指定することによってリンクの書式をカスタマイズすることができます。


メンションのリンクを赤色に、ハッシュタグをグレーの太字に変えてみます。

NSArray *textEentities = [TwitterText entitiesInText:text];
for (TwitterTextEntity *textEentity in textEentities) {
    if (textEentity.type == TwitterTextEntityScreenName) {
        NSString *screenName = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{
         @"NSLink": [NSString stringWithFormat:@"ftp:%@", screenName],
         NSForegroundColorAttributeName: [UIColor redColor]}
                                  range:textEentity.range];
    } else if (textEentity.type == TwitterTextEntityHashtag) {
        NSString *hashTag = [text substringWithRange:textEentity.range];
        [attributedString addAttributes:@{
         @"NSLink": [NSString stringWithFormat:@"maps:%@", hashTag],
         NSForegroundColorAttributeName: [UIColor grayColor],
                    NSFontAttributeName: [UIFont boldSystemFontOfSize:14.0f]}
                                  range:textEentity.range];
    }
}


↓ 実行結果は下のようになります。メンションとハッシュタグの書式が変わっているのがわかるでしょうか。
もちろんタップ可能なのは変わりません。


残念ながら、アンダーラインを消すことはできないようです。NSUnderlineStyleAttributeName をゼロに指定してみたのですが、効果はありませんでした。


それではここまでのコードを共有しておきます。
kishikawakatsumi/TextViewLinks · GitHub

解決が難しい問題

ただ、ここまでがんばっても UITextView を使う以上解決が難しい問題が残ります。
例えば、今回のテーブルビューセルに使うような場合だと、テキストビューの置いてあるところはテキストビューにタッチが取られてセルの選択ができない(テキストビューの userInteractionEnabled を NO にするとセルの選択はできるようになるが、今度はリンクがタップできなくなる)ことや、セルの選択をしたときに文字がハイライト色に変わらない(highlightedAttributedText のようなプロパティがあれば…)、などがあります。


これらの問題についてもがんばれば何とかなりそうですが、それをやろうとすると、そろそろコストが釣り合わないかなという気がします。
なのでそれ以上凝ったことをしたり見た目にこだわるのであれば、下記のようなサードパーティのライブラリの使用を検討してみたらいいのではないかと思います。


SECoreTextView は Mac/iOS の両方で簡単にリッチテキストやクリック可能なリンクを扱うことのできるライブラリです。
さらに任意の画像やビューを文字列と同様に取扱うこともできますので UIWebView のライトウェイトな代替コンポーネントとしても使用することができます。

kishikawakatsumi/SECoreTextView · GitHub