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をサイズ計測で利用することで、必要な描画領域を確保できる。
リポジトリ
参考
この記事が気に入ったらサポートをしてみませんか?