はじめに
SwiftUIで以下のようなX(Twitter)とかでよくある一定の文字数を超えると末尾に「... さらに表示する」と表示され一部文字が省略されるUIを実装してみようと思ったのですが、これが結構難しかったので実装方法を記事にしました。
実装方法
今回実装した方法としては、flowlayout(※)を作成して、文字を一文字のTextに分解してレイアウトしていき、最大文字数に達したら「... さらに表示」のボタンを配置するというものです。
※flowlayoutとは
FlowLayout は、左上から右下に向けて、部品を流し込むようにレイアウトします。HTML で文字や画像を流し込むのに似ています。
実装内容
1. flowlayoutの実装
struct FlowLayout: Layout {
var alignment: Alignment = .center
var spacing: CGFloat?
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) -> CGSize {
let result = FlowResult(
in: proposal.replacingUnspecifiedDimensions().width,
subviews: subviews,
spacing: spacing
)
return result.bounds
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) {
let result = FlowResult(
in: proposal.replacingUnspecifiedDimensions().width,
subviews: subviews,
spacing: spacing
)
for row in result.rows {
let rowXOffset = (bounds.width - row.frame.width) * alignment.horizontal.percent
for index in row.range {
let xPos = rowXOffset + row.frame.minX + row.xOffsets[index - row.range.lowerBound] + bounds.minX
let rowYAlignment = (row.frame.height - subviews[index].sizeThatFits(.unspecified).height) *
alignment.vertical.percent
let yPos = row.frame.minY + rowYAlignment + bounds.minY
subviews[index].place(at: CGPoint(x: xPos, y: yPos), anchor: .topLeading, proposal: .unspecified)
}
}
}
struct FlowResult {
var bounds = CGSize.zero
var rows = [Row]()
struct Row {
var range: Range<Int>
var xOffsets: [Double]
var frame: CGRect
}
init(in maxPossibleWidth: Double, subviews: Subviews, spacing: CGFloat?) {
var itemsInRow = 0
var remainingWidth = maxPossibleWidth.isFinite ? maxPossibleWidth : .greatestFiniteMagnitude
var rowMinY = 0.0
var rowHeight = 0.0
var xOffsets: [Double] = []
for (index, subview) in zip(subviews.indices, subviews) {
let idealSize = subview.sizeThatFits(.unspecified)
if index != 0 && widthInRow(index: index, idealWidth: idealSize.width) > remainingWidth {
// Finish the current row without this subview.
finalizeRow(index: max(index - 1, 0))
}
addToRow(index: index, idealSize: idealSize)
if index == subviews.count - 1 {
// Finish this row; it's either full or we're on the last view anyway.
finalizeRow(index: index)
}
}
func spacingBefore(index: Int) -> Double {
guard itemsInRow > 0 else { return 0 }
return spacing ?? subviews[index - 1].spacing.distance(to: subviews[index].spacing, along: .horizontal)
}
func widthInRow(index: Int, idealWidth: Double) -> Double {
idealWidth + spacingBefore(index: index)
}
func addToRow(index: Int, idealSize: CGSize) {
let width = widthInRow(index: index, idealWidth: idealSize.width)
xOffsets.append(maxPossibleWidth - remainingWidth + spacingBefore(index: index))
// Allocate width to this item (and spacing).
remainingWidth -= width
// Ensure the row height is as tall as the tallest item.
rowHeight = max(rowHeight, idealSize.height)
// Can fit in this row, add it.
itemsInRow += 1
}
func finalizeRow(index: Int) {
let rowWidth = maxPossibleWidth - remainingWidth
rows.append(
Row(range: index - max(itemsInRow - 1, 0) ..< index + 1,
xOffsets: xOffsets,
frame: CGRect(x: 0, y: rowMinY, width: rowWidth, height: rowHeight))
)
bounds.width = max(bounds.width, rowWidth)
let ySpacing = spacing ?? ViewSpacing().distance(to: ViewSpacing(), along: .vertical)
bounds.height += rowHeight + (rows.count > 1 ? ySpacing : 0)
rowMinY += rowHeight + ySpacing
itemsInRow = 0
rowHeight = 0
xOffsets.removeAll()
remainingWidth = maxPossibleWidth
}
}
}
}
private extension HorizontalAlignment {
var percent: Double {
switch self {
case .leading:
return 0
case .trailing:
return 1
default:
return 0.5
}
}
}
private extension VerticalAlignment {
var percent: Double {
switch self {
case .top:
return 0
case .bottom:
return 1
default:
return 0.5
}
}
}
こちらはAppleのサンプルアプリの実装を参考にしたものです。
この実装の詳細な説明に入ると、主題から逸れそうなのでここでは詳細な説明は省略します!(カスタムLayoutについては別記事として作りたい!)
とりあえず上記実装で、以下のようなflowlayoutが組めるようになったと思っていただければ良いと思います。
#Preview {
FlowLayout {
ForEach((1...10).map { _ in Int.random(in: 1...10) }, id: \.self) { num in
Text(String(num))
.frame(width: 30 * CGFloat(num))
.background(Color(Color(red: CGFloat(num) * 0.1, green: CGFloat(num) * 0.02, blue: CGFloat(num) * 0.3)))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
文字を分解してflowlayoutに詰めていく実装
まずは実装を全て載せておきます。
struct OmittableMessageView: View {
// MARK: - private property
@State private var isShowing: Bool
private let text: String
private let fontSize: CGFloat
private let maxLength: Int
private static let threePointLeader = "..."
// MARK: - initialize method
init(text: String,
fontSize: CGFloat = 14,
maxLength: Int = 200) {
let isShowing = text.count <= maxLength
self.text = text
self.fontSize = fontSize
self.maxLength = maxLength
self.isShowing = isShowing
}
// MARK: - view build definition
var body: some View {
if isShowing {
Text(text)
.font(.system(size: fontSize))
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
else {
VStack(alignment: .leading) {
ForEach(getSeparateMessageByNewLine(text: getMaxLengthText(text: text, maxLength: maxLength)), id: \.self) { lineText in
FlowLayout(alignment: .leading, spacing: .zero) {
let separateChars = getSeparateChar(text: lineText)
ForEach(separateChars, id: \.self) {
Text($0)
.font(.system(size: fontSize))
}
if let lastString = separateChars.last,
lastString == Self.threePointLeader {
createShowMoreButton()
}
}
}
}
.frame(maxWidth: .infinity)
}
}
}
private extension OmittableMessageView {
func createShowMoreButton() -> some View {
Button {
isShowing.toggle()
} label: {
Text("さらに表示")
.font(.system(size: fontSize))
}
.frameButtonStyle(backgroundColor: .clear,
frameWidth: .zero)
}
}
// MARK: - utility
private extension OmittableMessageView {
func getMaxLengthText(text: String, maxLength: Int) -> String {
return Array(text).prefix(maxLength).map { String($0) }.joined() + Self.threePointLeader
}
func getSeparateMessageByNewLine(text: String) -> [String] {
return text.components(separatedBy: "\n")
}
func getSeparateChar(text: String) -> [String] {
var arrayText = Array(text).map { String($0) }
if text.suffix(Self.threePointLeader.count) == Self.threePointLeader {
// 3点リーダーが末尾にある場合は、文字の配列で分割されている
// "."を"..."に結合させて設定する
arrayText = arrayText.dropLast(Self.threePointLeader.count)
arrayText.append(Self.threePointLeader)
}
return arrayText
}
}
以降が実装の詳細です
まずテキストを1文字ずつ分解してTextを生成する前に、元テキストの改行を表現するために改行ごとの配列に変換します。
その配列をForEachで回してVStackで囲み改行を表現します。
var body: some View {
VStack(alignment: .leading) {
// 改行ごとの文字列でForEachを回す
ForEach(getSeparateMessageByNewLine(text: getMaxLengthText(text: text, maxLength: maxLength)), id: \.self) { lineText in
...
}
}
.frame(maxWidth: .infinity)
}
func getSeparateMessageByNewLine(text: String) -> [String] {
return text.components(separatedBy: "\n")
}
またgetMaxLengthText
では最大文字数を超えた文字を消して末尾に3点リーダーを付与しています。
func getMaxLengthText(text: String, maxLength: Int) -> String {
// 前から最大文字数分取得して、3点リーダーを末尾に付与する
return Array(text).prefix(maxLength).map { String($0) }.joined() + Self.threePointLeader
}
ここまでで改行ごとのテキスト取得はできたので、これを一文字ずつに分解してTextとして表示していきます。
そして省略されている行の末尾にさらに表示ボタンを設定します。
var body: some View {
VStack(alignment: .leading) {
ForEach(getSeparateMessageByNewLine(text: getMaxLengthText(text: text, maxLength: maxLength)), id: \.self) { lineText in
let separateChars = getSeparateChar(text: lineText)
ForEach(separateChars, id: \.self) { // 一文字に分割した文字をTextにする
Text($0)
.font(.system(size: fontSize))
}
if let lastString = separateChars.last,
lastString == Self.threePointLeader {
createShowMoreButton() // 現在の行の最後に"..."があればさらに表示ボタンを配置する
}
}
}
.frame(maxWidth: .infinity)
}
// 一文字ずつの配列に変換する
func getSeparateChar(text: String) -> [String] {
var arrayText = Array(text).map { String($0) }
if text.suffix(Self.threePointLeader.count) == Self.threePointLeader {
// 3点リーダーが末尾にある場合は、文字の配列で分割されている
// "."を"..."に結合させて設定する
arrayText = arrayText.dropLast(Self.threePointLeader.count)
arrayText.append(Self.threePointLeader)
}
return arrayText
}
ここまでで一文字毎のTextを表示することができたので、作成したflowlayoutでTextを囲んでやればさらに表示するのUIが完成です。
var body: some View {
VStack(alignment: .leading) {
ForEach(getSeparateMessageByNewLine(text: getMaxLengthText(text: text, maxLength: maxLength)), id: \.self) { lineText in
FlowLayout(alignment: .leading, spacing: .zero) { // 一文字毎のTextをflowlayoutで表示する
let separateChars = getSeparateChar(text: lineText)
ForEach(separateChars, id: \.self) {
Text($0)
.font(.system(size: fontSize))
}
if let lastString = separateChars.last,
lastString == Self.threePointLeader {
createShowMoreButton()
}
}
}
}
.frame(maxWidth: .infinity)
}
最後にisShowing
の内容で省略するしないを切り替えるようにすれば完成です。
var body: some View {
if isShowing {
Text(text)
.font(.system(size: fontSize))
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
else {
VStack(alignment: .leading) {
ForEach(getSeparateMessageByNewLine(text: getMaxLengthText(text: text, maxLength: maxLength)), id: \.self) { lineText in
FlowLayout(alignment: .leading, spacing: .zero) {
let separateChars = getSeparateChar(text: lineText)
ForEach(separateChars, id: \.self) {
Text($0)
.font(.system(size: fontSize))
}
if let lastString = separateChars.last,
lastString == Self.threePointLeader {
createShowMoreButton()
}
}
}
}
.frame(maxWidth: .infinity)
}
}
以上で、さらに表示するのUIが完成しました。
#Preview {
VStack {
Text("Hello")
OmittableMessageView(text: "lsjfljslfjsiejflsjelfiasjfsfsd"
+ "\n"
+ "fsfsfsfsefsefasefasefasefsfsfefasefasefaselsaleifjlsesjeflesjlsijfseljfsifjelsjilsjfjelsijfselfijsliefjlsejjl"
+ "\n"
+ "jslejfiljsefljaslejfilsjefjlasjefljsleifjlsjflaisjfleijsaeljflsiejflisjelfiajslefjlasjeflsiejflsajeflijselfjilasejfliajseljflisejflasjlefjlsejfi")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
おわり
振り返ってみると結構ごちゃごちゃした実装になってしまいました。
もっとスマートに実装できるのではという思いもあるので、もし他にいい方法ご存知の方はコメントいただけますと幸いです。