IT系お父ちゃんのプログラミング日記

主にiPhoneやAndroidアプリのプログラミングについての記事を書きます。

UITextViewにFacebookのような「続きを読む」ボタンを追加する

UITextViewにFacebookのような「続きを読む」ボタンを追加する方法をご紹介します。
iOS7から導入されたTextKitを使用します。

ご紹介するこのサンプルでは、UITextViewに表示する行数をあらかじめ決めています。
例えば表示する行数が3行で、UITextViewに設定されたテキストが5行分の長さだった場合、
3行目の後ろ何文字かを「続きを読む」に置換します。4行目、5行目については、元のテキストから
削除してしまいます。

また、置換により挿入した「続きを読む」がタップされた場合、それを検知して
何らかの処理を行います。最初に「続きを読む」ボタンと言いましたが、
実際にはボタンではなく、タップされた位置に表示されている文字のインデックスから、
「続きを読む」がタップされたことを検知します。

では、まず、表示範囲内の最終行の一部文字列を「続きを読む」に置換する部分を
ご紹介します。UITextViewのExtensionとして実装しています。

extension UITextView {

    //続きを読むを追加する
    func addReadMore(maxLineCount: Int, word: String) -> NSRange? {
        //続きを読むのサイズを計測する・・・[1]
        let nsWord: NSString = word as NSString
        let wordSize = nsWord.sizeWithAttributes([NSFontAttributeName : self.font])
        
        //行を列挙する範囲を指定する・・・[2]
        let textRange = NSMakeRange(0, self.layoutManager.numberOfGlyphs)
        var lineIndex = 0        
        var replaceRange: NSRange = NSMakeRange(0, 0)
        
        //全行を列挙する
        self.layoutManager.enumerateLineFragmentsForGlyphRange(textRange, usingBlock: { (rect, usedRect, textContainer, glyphRange, stop) -> Void in
            
            lineIndex++
            
            //表示行数と現在の行インデックスが一致した場合
            if maxLineCount == lineIndex {
                //続きを読むと置換する領域を計算する
                let targetRect = CGRect(x: rect.width - wordSize.width, y: rect.origin.y,
                                                    width: wordSize.width, height: wordSize.height)
                let startPoint = self.layoutManager.glyphIndexForPoint(CGPoint(x: rect.width - wordSize.width, y: rect.origin.y),
                                                                                    inTextContainer: self.textContainer)

                replaceRange = NSMakeRange(startPoint, glyphRange.location + glyphRange.length - startPoint)
            }
        })
        //・・・[3]
        if maxLineCount < lineIndex {
            //置換する
            let readMoreAttr = [NSFontAttributeName : self.font, NSForegroundColorAttributeName : COLOR_READ_MORE]
            let readMoreText = NSAttributedString(string: word, attributes: readMoreAttr)
            self.textStorage.replaceCharactersInRange(replaceRange, withAttributedString: readMoreText)
            
            //置換文字列以降の文字列を削除する
            let deleteRange = NSMakeRange(replaceRange.location + countElements(word), self.textStorage.length - (replaceRange.location + countElements(word)))
            self.textStorage.deleteCharactersInRange(deleteRange)
            
            return NSMakeRange(replaceRange.location, nsWord.length)
        }
        
        return nil
    }
}

上記のソースを解説します。
addReadMoreメソッドは二つの引数を持っています。第一引数は表示行数、第二引数は「続きを読む」が設定される引数です。「続く」など「続きを読む」以外にしたい場合、第二引数を返ればよい形にしています。

まず、[1]の部分で「続きを読む」を表示するために必要な領域を計測しています。
表示行数が3行とした場合、3行目の末尾から①で計算した領域分のスペースをマイナスにした領域に
表示されている文字列と「続きを読む」を後ほど、置換します。

[2]では、NSLayoutManagerのenumerateLineFragmentsForGlyphRangeメソッドを使用して、
UITextViewに表示されている各行の表示領域を取得しています。NSLayoutManagerはTextKitの
中心的なクラスで、各文字(Glyph)のレイアウト関連の処理を担当しています。

enumerateLineFragmentsForGlyphRangeは行を発見する度にusingBlockに指定された
Closureを呼び出します。このClosureの中で行数をカウントしながら、3行目にきた場合、
[1]で計算した領域に表示されている文字のインデックスを取得しています。

[3]では[2]で取得したインデックスに表示されている文字から3行目の末尾の文字までを
「続きを読む」と置換し、それ以降の文字列を削除しています。

ただし、引数に指定された表示行数よりも、実際の行数が少ない場合、
つまり、テキスト全体が表示可能な場合、「続きを読む」を表示する必要がないため、
置換処理は行いません。

後ほど、「続きを読む」がタップされたことを検知する処理で必要になるため、
「続きを読む」が挿入された文字のインデックスの範囲を返却するようにしています。

「続きを読む」のタップ検知はUITapGestureRecognizerを使用します。
以下はUITapGestureRecognizerのアクションに指定したメソッド内の処理です。
通常はUIViewController内に記述すると良いと思います。

    func tapped(sender: UITapGestureRecognizer) {
        if let range = self.readMoreRange {
            
            let tappedPoint = sender.locationInView(self.contentTextView)
            let characterIndex = contentTextView.layoutManager.glyphIndexForPoint(tappedPoint,
                                            inTextContainer: self.contentTextView.textContainer)
            
            if range.location <= characterIndex && range.location + range.length > characterIndex {
                log("OK")
            }
        }
    }

self.readMoreRangeは先ほどのaddMoreメソッドが返却した「続きを読む」の
テキスト中のインデックスの範囲が格納されている変数です。

ここでもNSLayoutManagerを使用します。glyphIndexForPointメソッド
任意のポイントに配置されている文字のインデックスを取得するメソッドです。

以上です。