Kampf gegen Text-Kürzung in SwiftUI


Die Entwicklung der mobilen Vector Bible-App war mein erstes Projekt mit Swift und SwiftUI. Als Einsteiger stolpert man dabei natürlich über ein paar Probleme.

Eines der unangenehmeren war definitiv das automatische Kürzen von Text. Deshalb möchte ich teilen, warum ich dieses Problem meiner Meinung nach hatte, welche Vorschläge ich gefunden habe und was letztlich funktioniert hat.

Das Problem

Eine der großartigen Eigenschaften von SwiftUI ist, dass es Entwicklern vieles automatisch abnimmt. Theoretisch können wir dadurch Apps leichter und schneller bauen, die nativ sind und sich auch so anfühlen.

Eines dieser automatischen Verhaltensweisen ist die Text-Kürzung. Die Idee dahinter ist einfach: Man nimmt eine Text-Komponente, platziert sie irgendwo und gibt ihr vielleicht einige Größenparameter. SwiftUI kümmert sich dann um die Darstellung – der Text wird automatisch umbrochen und mit einer Ellipse (...) gekürzt, wenn er innerhalb der gegebenen Größenbeschränkungen nicht vollständig angezeigt werden kann. In der Praxis gibt es allerdings keinen unkomplizierten Weg, diese automatische Kürzung vollständig zu deaktivieren.

In Vector Bible hatte ich mehrere Textelemente vertikal in einer Liste gestapelt. Die Liste selbst war von weiteren Layout- und UI-Komponenten umgeben. Zusammen waren diese Textelemente meist höher als der Bildschirm, weshalb das gesamte Konstrukt irgendwo scrollbar gemacht wurde. Nennen wir es von nun an unseren Text-Stack. Ärgerlicherweise scheinen in diesem Text-Stack irgendwo Höhenbeschränkungen hineingeraten zu sein. Das Problem: Ich wusste weder warum noch wo, und auch nicht, wie ich das herausfinden könnte.

Ein Beispiel für Text-Kürzung

Vorgeschlagene Lösungen

Das war für mich eine ziemlich harte Nuss. Ich habe Stunden damit verbracht, zu recherchieren und vorgeschlagene Lösungen auszuprobieren.

Der beliebteste Vorschlag war, .fixedSize(horizontal: false, vertical: true) zu verwenden. Damit würden vertikale Beschränkungen ignoriert und der Text sollte sich über mehrere Zeilen umbrechen können. Dadurch verschwand die Kürzung aber nur an manchen Stellen und tauchte dafür an anderen wieder auf.

Manche empfahlen, .fixedSize() vor bestimmten anderen Modifiers anzuwenden, andere rieten zu .layoutPriority(1).

Ein eher schlechter Vorschlag, den ich leider oft gesehen habe, war .minimumScaleFactor(0.5), damit der Text sich innerhalb der gegebenen Beschränkungen automatisch verkleinert. Das kann in manchen Situationen passen, aber offensichtlich nicht, wenn viele gleich wichtige Textelemente vertikal gestapelt sind.

Ich habe auch versucht, ViewThatFits irgendwie einzusetzen, aber auch das hat mein Problem nicht gelöst.

Wie ich es gelöst habe

Nachdem ich also unzählige Stunden frustriert verbracht hatte und der Lösung keinen Schritt näher gekommen war, entschied ich mich, in den sauren Apfel zu beißen und mit UIKit eine eigene, in UIViewRepresentable verpackte Text-Komponente zu erstellen.

Ich hatte zuvor noch nie UIKit-Code geschrieben, deshalb ist das hier im Grunde vollständig KI-generiert.
Natürlich hat die Komponente ein paar Eigenheiten. Zum Beispiel muss man die maximale Breite fast immer manuell übergeben. Außerdem sieht sie in manchen Situationen einfach seltsam aus, weshalb ich diese Grenzen beim Design berücksichtigen musste.
Jedenfalls hat die Verwendung dieser Komponente anstelle von Text mein Problem mit Text-Kürzungen ein für alle Mal gelöst.

Der Code

Nutzt ihn gerne, wie es für euch passt. Ich hoffe, er hilft anderen, die mit diesem Problem kämpfen.

import SwiftUI

struct WrappingUILabel: UIViewRepresentable {
    var text: String
    var font: UIFont = UIFont.preferredFont(forTextStyle: .body)
    var multiplier: CGFloat = 1.0
    var textColor: UIColor = .label
    var textAlignment: NSTextAlignment = .natural
    var applyVerseNumberStyling: Bool = true
    var maxWidth: CGFloat? = nil
    var lineSpacing: CGFloat? = nil

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.isUserInteractionEnabled = true
        label.numberOfLines = 0
        label.lineBreakMode = .byWordWrapping
        let scaledFont = UIFontMetrics(forTextStyle: .body)
            .scaledFont(for: font.withSize(font.pointSize * multiplier))
        label.font = scaledFont
        label.adjustsFontForContentSizeCategory = true
        label.textColor = textColor
        label.textAlignment = textAlignment
        return label
    }

    func updateUIView(_ uiView: UILabel, context: Context) {
        let paragraphStyle = NSMutableParagraphStyle()
        // Ensure multi-line alignment and spacing are consistent
        paragraphStyle.alignment = textAlignment
        if let ls = lineSpacing {
            paragraphStyle.lineSpacing = ls
        } else {
            // Default: more spacing for verse text, tighter for plain/headings
            paragraphStyle.lineSpacing = applyVerseNumberStyling ? 12 : 0
        }

        let attrString: NSMutableAttributedString
        if applyVerseNumberStyling {
            if let spaceIndex = text.firstIndex(of: " ") {
                let verseNumber = String(text[..<spaceIndex])
                let remainder = String(text[text.index(after: spaceIndex)...])
                let verseNumberAttributed = NSMutableAttributedString(string: verseNumber + " ")
                verseNumberAttributed.addAttribute(
                    .foregroundColor,
                    value: UIColor.secondaryLabel,
                    range: NSRange(location: 0, length: verseNumberAttributed.length)
                )
                let remainderAttributed = NSMutableAttributedString(string: remainder)
                attrString = NSMutableAttributedString()
                attrString.append(verseNumberAttributed)
                attrString.append(remainderAttributed)
            } else {
                attrString = NSMutableAttributedString(string: text)
            }
        } else {
            attrString = NSMutableAttributedString(string: text)
        }

        // Apply paragraph style
        attrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attrString.length))
        uiView.attributedText = attrString

        let scaledFont = UIFontMetrics(forTextStyle: .body)
            .scaledFont(for: font.withSize(font.pointSize * multiplier))
        uiView.font = scaledFont
        uiView.adjustsFontForContentSizeCategory = true

        // Width for wrapping
        if let maxWidth = maxWidth {
            uiView.preferredMaxLayoutWidth = maxWidth
        } else {
            uiView.preferredMaxLayoutWidth = UIScreen.main.bounds.width - 40
        }
    }
}