iOS
UILabel
Swift

swiftでHTMLからNSAttributedStringを作る方法

More than 1 year has passed since last update.

この記事は Retty Advent Calendar 8日目です。
昨日は @koji-t さんの物体検出とボケ検出で一眼レフ風の料理写真候補抽出でした。

はじめに

RettyのiOSエンジニア兼なんでも屋の櫻井です。
今回のネタはswiftでHTMLからNSAttributedStringを作るというお話になります。

この記事を書くキッカケとなったのは、直近でリリースしました弊社iOSアプリのRettyのリニューアルにおいて NSAttributedStringを使うような表現にさらに多言語対応を含めたい という要件が発生したためでした。
さらに言うと formattedString を使って変数も埋め込みたい といった要件も重なり /(^o^)\ナンテコッタイ といったことになってしまいました。

まずは、この場合において何が課題となるのかを説明します。

課題となること

完成イメージはこのような感じです。
29470b35-8eed-7ef0-6c37-2169db34bbce.png

  • 要件の整理
    • 図のようにユーザ名と、いいねをもらった日付が変数として埋め込まれる
    • bold、文字サイズや色の違いが入る
    • さらに多言語対応も必要

NSMutableAttributedString を使って複数の NSAttributedString を連結することでももちろん作れますが、3段目にあるタイ語のように言語によっては文章の分割仕方の違いがあり、それだけで xx.string に作成するキーの数が増えてしまい、管理が面倒なのは勿論、バグを生み出す元にもなり、またNSMutableAttributedStringで連結するためのコードも面倒なものになっていってしまいます。

swiftでHTMLからNSAttributedStringを作る方法

NSAttributedStringを複雑に組みたい場合の背景を説明したところで本題です。
今回コードを調べて行く中で NSAttributedString のイニシャライザに options として
documentType というパラメータを指定でき、さらに NSAttributedString.DocumentType.html なるものがあることを知りました。

つまりHTMLの文字列からなんとNSAttributedStringを生成することが可能なのです。
ですが、この生成についてもいくつかの課題があり一筋縄ではいきませんでした。

結論となるコード

今回の話のサンプルコードをGithubにあげておきました。
Starとかもらったことあまりないので、参考になったならもらえると筆者が泣いて喜びますw
https://github.com/saku/HtmlLabel

サンプルプロジェクト内にある String.swift が今回のテーマとなる String の extension になります。

import UIKit

extension String {
    func convertHtml(withFont: UIFont? = nil, align: NSTextAlignment = .left) -> NSAttributedString {
        if let data = self.data(using: .utf8, allowLossyConversion: true),
            let attributedText = try? NSAttributedString(
                data: data,
                options: [.documentType: NSAttributedString.DocumentType.html,
                          .characterEncoding: String.Encoding.utf8.rawValue],
                documentAttributes: nil
            ) {
            let style = NSMutableParagraphStyle()
            style.alignment = align

            let fullRange = NSRange(location: 0, length: attributedText.length)
            let mutableAttributeText = NSMutableAttributedString(attributedString: attributedText)

            if let font = withFont {
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)
                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        let traits: UIFontDescriptorSymbolicTraits = attributeFont.fontDescriptor.symbolicTraits
                        var newDescripter = attributeFont.fontDescriptor.withFamily(font.familyName)
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitBold.rawValue) != 0 {
                            newDescripter = newDescripter.withSymbolicTraits(.traitBold)!
                        }
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitItalic.rawValue) != 0 {
                            newDescripter = newDescripter.withSymbolicTraits(.traitItalic)!
                        }
                        let scaledFont = UIFont(descriptor: newDescripter, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })
            }

            return mutableAttributeText
        }

        return NSAttributedString(string: self)
    }
}

課題その1 なぜかフォントがlabelの指定のものと変わる

なぜかNSAttributedString単純にテキストを生成するとフォントの指定が勝手に変わります\(^o^)/
生成されるタイミングで色々な情報が失われるようで、storyboardのfontを指定してもここでは意味がありません。
フォントを正しいものにするためには自らstoryboardで設定したフォントを生成時のメソッドに渡して、その中で設定してあげる必要があります。

具体的にはサンプルコードのこの部分となります。

            if let font = withFont {
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)
                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        // ・・・中略
                        var newDescripter = attributeFont.fontDescriptor.withFamily(font.familyName)
                        // ・・・中略
                        let scaledFont = UIFont(descriptor: newDescripter, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })
            }

課題その2 bold指定やitalic指定が消える

課題1で指定した元ラベルのフォントを設定することでフォントファミリーやフォントサイズが指定のものと変わってしまう問題が解決できましたが、storyboard 上の設定をそのまま使っているためこのままではbold指定やitalicの指定が反映されません。

そこで、HTMLから生成したAttributedStringをattributeの配列に分割し、その分割結果からフォントを取得し、さらにそのフォントからfont descripterというものを取得します。
font descripter は symbolicTraits というプロパティがあり、これにboldやitalicといった文字スタイルに関する情報が含まれています。
このtraitsから情報を取得して反映することになるのですが、情報の取得には昔ながらのビット演算を使う必要があります。

具体的にはサンプルコードのこの部分となります。

                mutableAttributeText.enumerateAttribute(.font, in: fullRange, options: .longestEffectiveRangeNotRequired, using: { attribute, range, _ in
                    if let attributeFont = attribute as? UIFont {
                        // ここで traits を取得
                        let traits: UIFontDescriptorSymbolicTraits = attributeFont.fontDescriptor.symbolicTraits
                        var newDescripter = attributeFont.fontDescriptor.withFamily(font.familyName)
                        // ビット演算をしてboldのフラグがたっているようならば新しく設定するfont descripterにも反映
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitBold.rawValue) != 0 {
                            newDescripter = newDescripter.withSymbolicTraits(.traitBold)!
                        }
                        // 同様にitalicも設定
                        if (traits.rawValue & UIFontDescriptorSymbolicTraits.traitItalic.rawValue) != 0 {
                            newDescripter = newDescripter.withSymbolicTraits(.traitItalic)!
                        }
                        let scaledFont = UIFont(descriptor: newDescripter, size: attributeFont.pointSize)
                        mutableAttributeText.addAttribute(.font, value: scaledFont, range: range)
                    }
                })

課題その3 text align(中央寄せ等)の情報が消える

課題1の話と関連して、HTMLから変換した文字は全てデフォルト左寄せとなります。
対策としてはAttributedStringには paragraphStyle という形で text align の情報を付加することで解決します。
(今回の方法では左寄せと中央寄せと右寄せとが色々複合したラベルを作る方法には対応していません)

具体的にはサンプルコードのこの部分となります。

            let style = NSMutableParagraphStyle()
            style.alignment = align

            // ・・・中略

            if let font = withFont {
                // ここでalignを設定
                mutableAttributeText.addAttribute(.paragraphStyle, value: style, range: fullRange)

おわりに

最初にHTMLをNSAttributedStringに変換できると知ったときには、「勝ったな・・・!」と思っていたのですが、そこからの道のりが意外と遠く険しくでビックリしました(´・ω・`)
同じ苦しみを皆様が味わうことがなければ幸いです m(_ _)m

明日は @noripi さんの Kotlin/Nativeを使ってiOSアプリを作ってみる です。
みなさん、お楽しみに!