見出し画像

SwiftUIでTruncated Text

SwiftUIにはlineLimitというモディファイアがあり、テキストが表示できる行数を制限することができ、省略される場合は末尾に...が表示されるが、...Moreのように任意の省略文字を指定できない。
そこで今回はこちらの記事を参照にして、省略文字やそのスタイルを任意指定できるようにしてみた。

struct ContentView: View {
    let text = "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。"

    var body: some View {
        VStack(spacing: 40) {
            Text(text)
                .lineLimit(3)

            TrancatedText(text,
                          lineLimit: 3,
                          ellipsis: .init(text: "More", color: .blue))
        }
        .padding()
    }
}

Truncated Text

今回の方法の肝は、backgroundにサイズ計測用のレイヤーを非表示用意して、二分探索でテキストを省略しながら、表示領域に収まるまで省略テキストを更新するところにある。
計測用のレイヤーにlineLimitモディファイアを適用させ、さらにbackgroundにGeometryReaderを追加することで、そのレイヤー自体のサイズを取得する。
そして、二分探索でテキストを省略しながら、NSAttributedStringを使って固定幅に対するテキストの高さを取得して、その高さがvisibleTextGeometry.size.height以下になったら終了。こうしてちょうど表示領域に収まるような省略テキスト(省略文字を含む)を得ることができる。

struct TrancatedText: View {
    struct Ellipsis {
        var text: String = ""
        var color: Color = .black
        var fontSize: CGFloat?
    }

    @State private var truncated: Bool = false
    @State private var truncatedText: String

    let lineLimit: Int
    let lineSpacing: CGFloat
    let font: UIFont
    let ellipsis: Ellipsis

    private var text: String
    private var ellipsisPrefixText: String {
        return truncated ? "... " : ""
    }

    private var ellipsisText: String {
        return truncated ? ellipsis.text : ""
    }

    private var ellipsisFont: Font {
        if let fontSize = ellipsis.fontSize {
            return Font(font.withSize(fontSize))
        } else {
            return Font(font)
        }
    }

    init(
        _ text: String,
        lineLimit: Int,
        lineSpacing: CGFloat = 2,
        font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body),
        ellipsis: Ellipsis = Ellipsis()) {
        self.text = text
        self.lineLimit = lineLimit
        self.lineSpacing = lineSpacing
        self.font = font
        self.ellipsis = ellipsis

        _truncatedText = State(wrappedValue: text)
    }

    var body: some View {
        Group {
            // 省略されたテキスト
            Text(truncatedText)
                + Text(ellipsisPrefixText)
                + Text(ellipsisText)
                .font(ellipsisFont)
                .foregroundColor(ellipsis.color)
        }
        .multilineTextAlignment(.leading)
        .lineLimit(lineLimit)
        .lineSpacing(lineSpacing)
        .background(
            // lineLimitで制限されたテキストをレンダリングして、そのサイズを計測しながら表示できるテキストを更新する
            Text(text)
                // 計測用のレイヤーなので非表示にする
                .hidden()
                .lineLimit(lineLimit)
                .background(GeometryReader { visibleTextGeometry in
                    Color.clear
                        .onAppear {
                            let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude)
                            let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font]

                            // 二分探索で省略テキストを更新する
                            // 終了条件: mid == low && mid == high
                            var low = 0
                            var heigh = truncatedText.count
                            var mid = heigh

                            while (heigh - low) > 1 {
                                // 固定幅に対するテキストの高さを取得するためにNSAttributedStringを用いる
                                let attributedText = NSAttributedString(
                                    string: truncatedText + ellipsisPrefixText + ellipsisText,
                                    attributes: attributes)
                                let boundRect = attributedText.boundingRect(
                                    with: size,
                                    options: NSStringDrawingOptions.usesLineFragmentOrigin,
                                    context: nil)

                                if boundRect.size.height > visibleTextGeometry.size.height {
                                    truncated = true
                                    heigh = mid
                                    mid = (heigh + low) / 2
                                } else {
                                    if mid == text.count {
                                        break
                                    } else {
                                        low = mid
                                        mid = (low + heigh) / 2
                                    }
                                }

                                // truncatedTextの更新による再描画はSwiftUIが管理しており、この二分探索の処理による再描画は最終更新後に行われた
                                truncatedText = String(text.prefix(mid))
                            }
                        }
                }))
        .font(Font(font))
    }
}

補足

truncatedTextは@Stateとして宣言しているが、truncatedTextの更新による再描画はSwiftUIが管理しており、ループの度に再描画が行われるわけではなさそう。実際にlet _ = Self._printChanges()で確認してみたが、初期描画後は最後の更新後のみ再描画が行われた。

追記

テキスト内で短い改行があると、サイズ計測時に必要な描画領域を確保できずに、末尾が必要以上に省略されてしまう問題があったため、こちらのコミットで修正した。親ViewからGeometryProxyを渡して、そのwidthをサイズ計測で利用することで、必要な描画領域を確保できる。

リポジトリ

参考


この記事が気に入ったらサポートをしてみませんか?