在 SwiftUI 中,如何判断 Text 是否被截断?

发表于

Text 在 SwiftUI 中大量被使用,与 UIKit/AppKit 中对应的组件相比,Text 无需配置,开箱即用,但这也意味着开发者丧失了更多对其的控制能力。在本文中,我们将通过一个实际案例来展示,如何用 SwiftUI 的方式来完成一些看似”不可能”的任务:在一堆给定的视图中,找出第一个文本未被截断的,并以此作为需求尺寸

一个有趣的挑战

几天前,我收到了来自 Marc 的邮件。他在开发 GNU Taler 的 iOS 客户端时,遇到了一个有趣的布局适配问题:

在列表中需要显示包含描述和金额的视图,为了确保描述和金额的内容都能完整显示,Marc 预设了多种不同的布局方案:

  • Compact 布局:单行描述 + 水平排列
  • Standard 布局:最多两行描述 + 水平排列
  • Extended 布局:不限行数 + 垂直排列

Marc 希望能按照优先级顺序(Compact → Standard → Extended),自动选择第一个能让文本完整显示(不被截断)的布局方案。

Swift
struct ContentView1: View {
    // MARK: - Sample Data
    private let short = "short Text"
    private let medium =
        "some text which can obviously be wrapped in two lines"
    private let long = "A lot of text which can obviously be wrapped in many lines!"

    var body: some View {
        List {
            AdaptiveAmountRow(title: short)
            AdaptiveAmountRow(title: medium)
            AdaptiveAmountRow(title: long)
        }
    }
}

// 三种预设布局方案

/// Compact layout - 单行水平排列
HStack(alignment: .center) { 
    titleView // maxLines = 1
    Spacer(minLength: 2)
    amountView.fixedSize() // 金额永不截断
}

/// Standard layout - 两行水平排列
HStack(alignment: .lastTextBaseline) {
    titleView // maxLines = 2
    Spacer(minLength: 2)
    amountView.fixedSize()
}

/// Extended layout - 垂直排列
VStack(alignment: .leading) {
    titleView // maxLines = nil (不限制)
    HStack {
        Spacer()
        amountView.fixedSize()
    }
}

在这个方案中,AdaptiveAmountRow 会根据文本内容的长度在三种预设布局中智能选择:

  1. 如果文本可以在 Compact Layout 中完整显示,优先使用单行布局
  2. 如果需要换行,则尝试 Standard Layout 的两行布局
  3. 如果文本过长,最终选择 Extended Layout,确保所有内容都能完整展示

image-20250707150654025

我在上面的描述中,对 Marc 的实际需求进行了简化,但保留了其中的关键信息。

挑战来自哪里?

我们面临两个核心挑战:

  1. 如何判断 Text 是否被截断 - SwiftUI 的 Text 并不会给出任何有关文本是否被完整显示的信息。当空间有限时,它只能选择用尽可用空间,并根据截断规则智能截断内容
  2. 如何智能选择和应用布局 - 找出第一个没有被截断的视图,并将它的尺寸作为容器的需求尺寸

最终目标:构建一个智能容器,它能从多个预设布局中找出第一个可以完整显示文本内容的方案,并自动应用该布局。

以子之矛,攻子之盾 —— 用 SwiftUI 的方式判断文本是否被截断

或许不少开发者在尝试判断 Text 是否被完整显示时会率先想到 NSAttributedString。通过 boundingRect 等方法,NSAttributedString 具备在给定约束条件(比如限定宽度、限定高度)下,计算文本布局所需尺寸的能力。不过,这种方式对于 Marc 的需求来说并不完全适合。由于需要准确获取 SwiftUI Text 的字体信息(包括动态类型调整),以及 SwiftUI 在文本渲染和截断处理上的细微差异,因此应用这种方式不仅麻烦,也容易出现误判。

虽然 SwiftUI 的 Text 没有提供是否被完整显示状态的能力,但对于开发者来说,在一个维度被限定时,要求 Text 无视另一个维度的建议尺寸而确保完整展示并不困难。通过使用 fixedSize + GeometryReader,我们便可以获取到在一个维度尺寸固定的情况下,完整显示当前文字在另一个维度需要的尺寸信息。

Swift
struct Dimension: View {
    private let long = "A lot of text which can obviously be wrapped in many lines!"
    var body: some View {
        Text(long)
            .border(Color.red, width: 2)
            .fixedSize(horizontal: false, vertical: true)
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .task(id: geometry.size) {
                            print(geometry.size) // 100.0 x 108.3
                        }
                })
            .frame(width: 100, height: 50)
            .border(.blue, width: 2)
    }
}

image-20250707154429657

在上面的代码中,尽管我们通过 .frame(width: 100, height: 50) 在水平和垂直方向分别限制了 Text 的可用空间,但 .fixedSize(horizontal: false, vertical: true) 则告诉 Text,让其无视垂直方向的限制,确保文字被完整显示。通过 GeometryReader,我们可以看到,在宽度限制为 100 的情况下,完整显示全部文本需要 108.3 的纵向尺寸。

同样,我们也可以用相同的原理,来判断当高度被限定后,最少需要多少的宽度才能完整显示内容。

也就是说,现在我们具备了找出一个 Text 在某个维度被限制的情况下,完整显示后另一个维度需要多少空间的方式。这样我们就可以获得以下三个尺寸:

  • Text 当前在给定建议尺寸的情况下,最终展示后占用的尺寸
  • 基于当前的水平建议尺寸,完整显示 Text 后,垂直方向上需要的尺寸
  • 基于当前的垂直建议尺寸,完整显示 Text 后,水平方向上需要的尺寸

通过将第一个尺寸的高度和宽度,分别和后两个尺寸的高度和宽度比较,我们就可以获知 Text 是否可以在当前给定的建议尺寸中被完整显示。

Swift
struct Dimension: View {
    private let long = "A lot of text which can obviously be wrapped in many lines!"
    @State private var displayDimension: CGSize?
    @State private var verticalDimension: CGSize?
    @State private var horizontalDimension: CGSize?
		
    var isTruncated: Bool? {
        guard let displayDimension, let verticalDimension, let horizontalDimension else { return nil }
        // 检查是否在任一维度上被截断
        if displayDimension.width > verticalDimension.width || displayDimension.height > horizontalDimension.height {
            return true
        } else {
            return false
        }
    }
    
    var body: some View {
        Text(long)
            .getDimension(dimension: $displayDimension)
            .background(
                Text(long) // 在宽度限制的情况下,完整显示最少需要的高度
                    .fixedSize(horizontal: false, vertical: true)
                    .hidden()
                    .getDimension(dimension: $verticalDimension)
             )
            .background(
                Text(long) // 在高度限制的情况下,完整显示最少需要的宽度
                    .fixedSize(horizontal: true, vertical: false)
                    .hidden()
                    .getDimension(dimension: $horizontalDimension)
                )
            .frame(width: 100, height: 50)
        
        if let isTruncated  {
            Text(isTruncated ? "Truncated" : "Not Truncated")
        }
    }
}

image-20250707160718136

将文本内容减少后,可以看到 isTruncated 显示为 false 了。

image-20250707160849494

尽管我们需要通过增加两次计算的手段,但至少我们实现了第一个核心诉求:判断 Text 是否被截断

现在我们只需将 isTruncated 的结果选择一种合适的手段来通知父视图即可,在实际的解决方案中,我选择了使用 PreferenceKey

另类的 ViewThatFits

从一堆给定的子视图中找出第一个满足条件的,这种需求很像 ViewThatFits 的应用场景。但具体到当前的挑战来说,显然 ViewThatFits 并不适合。

比如,在三个预设的布局中,由于 Text 会自动截断文本,因此 ViewThatFits 并不能根据文本的截断与否进行判断。显然我们需要创建一个自定义规则的智能布局选择器

由于我们已经掌握了某种通知机制,让这个选择器可以获知某个子视图中的 Text 是否被截断,那么最重要的是要解决:如何让智能布局选择器使用最终选择的布局方式的需求尺寸作为自己的需求尺寸?

Layout 协议?不是不行,但受限于该协议在获取文本截断状态的方式比较受限,因此我选择了不久前在文章中介绍的 ZStack + layoutPriority 解决方案。

探索 SwiftUI ZStack 中的 layoutPriority 奥秘一文中,我们探讨了 ZStack 布局上的一个有趣规则:ZStack 并不简单地取包含所有子视图的最小边界,它只考虑具有最高 layoutPriority 的子视图集合,计算出能同时容纳它们的最小对齐尺寸,作为自身的需求尺寸。

这意味着,如果我们将所有的预设布局都放置在同一个 ZStack 中,根据优先级逐个检查,找到第一个 Text 完整显示的布局,并将其 layoutPriority 的优先级设置高于其他的,同时隐藏其他布局的显示。此时,ZStack 就将以这个预设布局的需求尺寸,作为自己的需求尺寸。从而实现了我们自己的智能布局选择器:找到第一个满足要求的布局,并以它的需求尺寸作为最终的需求尺寸

大致的实现逻辑为:

Swift
struct ZStackContainer: View {
    @State private var layoutStatuses: [LayoutMode: Bool] = [:]

    /// The first layout mode that can display the content without truncation
    private var optimalLayoutMode: LayoutMode? {
        return layoutStatuses.keys
            .sorted(by: { $0 < $1 })
            .first { !(layoutStatuses[$0] ?? true) }
    }

    private func isLayoutSelected(_ mode: LayoutMode) -> Bool {
        return optimalLayoutMode == mode
    }
    
    var body: some View {
        ZStack {
            Compact()
                .layoutPriority(isLayoutSelected(.compact) ? 2 : 1)
                .opacity(isLayoutSelected(.compact) ? 1 : 0)

            Standard()
                .layoutPriority(isLayoutSelected(.standard) ? 2 : 1)
                .opacity(isLayoutSelected(.standard) ? 1 : 0)

            Extended()
                .layoutPriority(isLayoutSelected(.extended) ? 2 : 1)
                .opacity(isLayoutSelected(.extended) ? 1 : 0)
        }
    }
}

至此,我们便用纯 SwiftUI 的方式完成了本次挑战,实现了最终目标。

开源与 Taler

尽管第一时间我便想通过博客分享该案例的解决思路,但由于本次是一个有偿委托,我还是需要征求 Marc 是否可以公开这个解题思路。Marc 很痛快地便答应了我的请求。他告诉我,GNU Taler 本身就是一个基于自由软件的数字支付系统,专门设计用来提供隐私友好的在线交易。它不使用区块链技术,而是采用盲签名来保护用户隐私。

这意味着,Taler 是拥抱开源的项目,同时它也接受每个使用者的检验。对于一个注重隐私的交易系统来说,透明是其获取用户信任的重要基石

感谢 Marc 的许可,我将上述思路整理成完整的代码,放置在了 GitHub Gist 上,供大家参考。

下图展示了该智能布局方案在 Taler Wallet 中的实际应用效果:

taler-wallet-iShot_2025-07-08_14.32.15

从左到右的三个截图可以清楚地看到,系统根据不同长度的交易描述文本,自动选择了最适合的布局方式,确保所有信息都能完整显示,同时保持界面的整洁美观。

融汇方可贯通

在过去几年中,随着我对 SwiftUI 的理解加深,我愈发觉得在使用 SwiftUI 进行布局时,深入理解其底层机制能更好地帮助你应对不断复杂化的布局需求。

就当前的案例而言,几乎每个具体的知识点都在我的博客中有专门的主题文章进行过探讨:

  • fixedSize 的灵活运用
  • GeometryReader 的尺寸测量
  • background 不影响复合视图的需求尺寸
  • PreferenceKey 的数据向上传递
  • ZStacklayoutPriority 机制

只有当你能充分掌握并将这些机制融汇在一起时,才能实现一些看似”不可能”的任务

SwiftUI 的使用门槛很低,但用好它并不容易。这正是技术学习的魅力所在——每一个看似独立的知识点,都可能在某个时刻成为解决复杂问题的关键钥匙。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!