2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIでさらに表示するのUIを作る

Posted at

はじめに

SwiftUIで以下のようなX(Twitter)とかでよくある一定の文字数を超えると末尾に「... さらに表示する」と表示され一部文字が省略されるUIを実装してみようと思ったのですが、これが結構難しかったので実装方法を記事にしました。

省略時のUI
image.png

さらに表示押下後のUI
image.png

実装方法

今回実装した方法としては、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が組めるようになったと思っていただければ良いと思います。

image.png

#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が完成しました。

image.png

#Preview {
    VStack {
        Text("Hello")
        OmittableMessageView(text: "lsjfljslfjsiejflsjelfiasjfsfsd"
                    + "\n"
                    + "fsfsfsfsefsefasefasefasefsfsfefasefasefaselsaleifjlsesjeflesjlsijfseljfsifjelsjilsjfjelsijfselfijsliefjlsejjl"
                    + "\n"
                    + "jslejfiljsefljaslejfilsjefjlasjefljsleifjlsjflaisjfleijsaeljflsiejflisjelfiajslefjlasjeflsiejflsajeflijselfjilasejfliajseljflisejflasjlefjlsejfi")
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}

おわり

振り返ってみると結構ごちゃごちゃした実装になってしまいました。
もっとスマートに実装できるのではという思いもあるので、もし他にいい方法ご存知の方はコメントいただけますと幸いです。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?