You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI自定义布局中不同类型组件换行异常问题

问题分析

你的自定义CustomLayoutSec布局逻辑存在核心问题:当前的行生成逻辑将所有组件(包括可换行的文本)都视为不可拆分的固定宽度元素。当混合不同类型组件时,文本组件无法利用剩余空间自动换行,导致空间不足时直接将后续组件挤到新行,或者文本组件自身独占一行,引发布局异常。

解决方案

我们需要调整两个核心部分:

  • 让文本组件支持根据剩余空间自动换行
  • 重构自定义Layout的行计算逻辑,区分可换行组件(文本)和不可换行组件(链接、@用户名),合理分配空间

修改后的完整代码

import SwiftUI

private let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)

struct HustleInlineLink: View {
    let text: String
    let links: [NSTextCheckingResult]
    
    init (_ text: String) {
        self.text = text
        let nsText = text as NSString
        let wholeString = NSRange(location: 0, length: nsText.length)
        links = linkDetector.matches(in: text, options: [], range: wholeString)
    }
    
    var body: some View {
        HustleInlineLinkView(text: text, links: links)
    }
}

struct HustleInlineLinkView: View {
    @State var showProfile: Bool = false
    @State var selectedUser: String? = nil
    @Environment(\.colorScheme) var colorScheme
    @EnvironmentObject var popRoot: PopToRoot
    
    enum Component {
        case text(String)
        case link(String, URL)
        case username(String, String)
        
        // 标记组件是否可换行
        var isLineBreakable: Bool {
            switch self {
            case .text: return true
            default: return false
            }
        }
    }

    let text: String
    let components: [Component]

    init(text: String, links: [NSTextCheckingResult]) {
        self.text = text
        let nsText = text as NSString
        
        var components: [Component] = []
        var index = 0
        for result in links {
            if result.range.location > index {
                let sub_str = nsText.substring(with: NSRange(location: index, length: result.range.location - index))
                let ats = separateTextAndTags(inputString: sub_str)
              
                ats.forEach { element in
                    if element.hasPrefix("@") && !element.trimmingCharacters(in: .whitespaces).isEmpty {
                        components.append(.username(element, element))
                    } else {
                        components.append(.text(element))
                    }
                }
            }
            components.append(.link(nsText.substring(with: result.range), result.url!))
            index = result.range.location + result.range.length
        }
        if index < nsText.length {
            let sub_str = nsText.substring(from: index)
            
            let ats = separateTextAndTags(inputString: sub_str)
            ats.forEach { element in
                if element.hasPrefix("@") && !element.trimmingCharacters(in: .whitespaces).isEmpty {
                    components.append(.username(element, element))
                } else {
                    components.append(.text(element))
                }
            }
        }
        self.components = components
    }
    
    var body: some View {
        CustomLayoutSec {
            ForEach(Array(components.enumerated()), id: \.offset) { idx, component in
                switch component {
                case .text(let text):
                    Text(verbatim: text)
                        .font(.subheadline)
                        .multilineTextAlignment(.leading)
                        .simultaneousGesture(LongPressGesture().onEnded { _ in
                            
                        })
                        // 允许文本组件根据可用空间换行
                        .frame(maxWidth: .infinity, alignment: .leading)
                case .link(let text, _):
                    Text(verbatim: text)
                        .foregroundColor(.blue)
                        .font(.subheadline)
                        .multilineTextAlignment(.leading)
                        .simultaneousGesture(LongPressGesture().onEnded { _ in
                            //do something
                        })
                        .simultaneousGesture(TapGesture().onEnded {
                            //open link
                        })
                case .username(let text, _):
                    Button(action: {
                        
                    }, label: {
                        Text(text)
                            .font(.subheadline).bold()
                            .multilineTextAlignment(.leading)
                            .foregroundStyle(.orange)
                    })
                }
            }
        }
    }
}

struct CustomLayoutSec: Layout {
    var alignment: Alignment = .leading
    var spacing: CGFloat = 0
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? 0
        guard maxWidth > 0 else { return .zero }
        
        var totalHeight: CGFloat = 0
        var currentRowWidth: CGFloat = 0
        var currentRowMaxHeight: CGFloat = 0
        
        for (idx, subview) in subviews.enumerated() {
            // 获取当前组件类型(通过视图关联的Component判断)
            let componentTag = subview[id: \.tag] as? Int ?? 0
            let component = Component(rawValue: componentTag) ?? .text("")
            
            let availableWidth = maxWidth - currentRowWidth - (currentRowWidth > 0 ? spacing : 0)
            let viewSize = subview.sizeThatFits(.init(
                width: component.isLineBreakable ? availableWidth : nil,
                height: nil
            ))
            
            // 检查当前组件是否能加入当前行
            if currentRowWidth + viewSize.width + (currentRowWidth > 0 ? spacing : 0) <= maxWidth || component.isLineBreakable {
                // 可加入当前行,更新行宽和行高
                currentRowWidth += viewSize.width + (currentRowWidth > 0 ? spacing : 0)
                currentRowMaxHeight = max(currentRowMaxHeight, viewSize.height)
            } else {
                // 无法加入,换行
                totalHeight += currentRowMaxHeight + spacing
                currentRowWidth = viewSize.width
                currentRowMaxHeight = viewSize.height
            }
            
            // 最后一个组件,加入总高度
            if idx == subviews.count - 1 {
                totalHeight += currentRowMaxHeight
            }
        }
        
        return .init(width: maxWidth, height: totalHeight)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let maxWidth = bounds.width
        guard maxWidth > 0 else { return }
        
        var origin = bounds.origin
        var currentRowWidth: CGFloat = 0
        var currentRowMaxHeight: CGFloat = 0
        
        for (idx, subview) in subviews.enumerated() {
            // 获取当前组件类型
            let componentTag = subview[id: \.tag] as? Int ?? 0
            let component = Component(rawValue: componentTag) ?? .text("")
            
            let availableWidth = maxWidth - currentRowWidth - (currentRowWidth > 0 ? spacing : 0)
            let viewSize = subview.sizeThatFits(.init(
                width: component.isLineBreakable ? availableWidth : nil,
                height: nil
            ))
            
            // 检查是否需要换行(仅针对不可换行组件)
            if currentRowWidth + viewSize.width + (currentRowWidth > 0 ? spacing : 0) > maxWidth && !component.isLineBreakable {
                // 换行,更新Y坐标
                origin.y += currentRowMaxHeight + spacing
                currentRowWidth = 0
                currentRowMaxHeight = 0
            }
            
            // 放置组件
            let xOffset = currentRowWidth + (currentRowWidth > 0 ? spacing : 0)
            subview.place(
                at: .init(x: bounds.minX + xOffset, y: origin.y),
                proposal: .init(
                    width: component.isLineBreakable ? availableWidth : viewSize.width,
                    height: viewSize.height
                )
            )
            
            // 更新行状态
            currentRowWidth += viewSize.width + (currentRowWidth > 0 ? spacing : 0)
            currentRowMaxHeight = max(currentRowMaxHeight, viewSize.height)
        }
    }
}

// 给Component添加RawValue用于标记视图
extension HustleInlineLinkView.Component: RawRepresentable {
    typealias RawValue = Int
    
    init?(rawValue: Int) {
        switch rawValue {
        case 0: self = .text("")
        case 1: self = .link("", URL(string: "")!)
        case 2: self = .username("", "")
        default: return nil
        }
    }
    
    var rawValue: Int {
        switch self {
        case .text: return 0
        case .link: return 1
        case .username: return 2
        }
    }
}

extension [LayoutSubviews.Element] {
    func maxHeight(_ proposal: ProposedViewSize) -> CGFloat {
        return self.compactMap { view in
            return view.sizeThatFits(proposal).height
        }.max() ?? 0
    }
}

func separateTextAndTags(inputString: String) -> [String] {
    var resultArray: [String] = []
    var currentToken = ""

    for character in inputString {
        if character == "@" {
            if !currentToken.isEmpty {
                resultArray.append(currentToken)
                currentToken = ""
            }
            currentToken += String(character)
        } else if character.isWhitespace {
            if !currentToken.isEmpty {
                resultArray.append(currentToken)
                currentToken = ""
            }
            currentToken += String(character)
        } else {
            currentToken += String(character)
        }
    }

    if !currentToken.isEmpty {
        resultArray.append(currentToken)
    }

    return resultArray
}

关键修改点说明

  1. 组件可换行标记:给Component枚举添加isLineBreakable属性,标记文本组件为可换行,链接和@用户名为不可换行;同时实现RawRepresentable协议,用于在Layout中识别组件类型。
  2. 文本组件布局优化:给Text组件添加.frame(maxWidth: .infinity, alignment: .leading),允许其根据可用空间自动换行。
  3. 自定义Layout逻辑重构:
    • sizeThatFits:针对可换行组件动态计算可用宽度,避免强行换行;不可换行组件则判断是否能加入当前行,否则换行,准确计算总高度。
    • placeSubviews:根据组件类型动态分配宽度,文本组件使用剩余空间,确保不同类型组件能在同一行合理排列,仅对不可换行组件触发换行逻辑。
  4. 文本拆分逻辑优化:修正separateTextAndTags的空格处理,避免不必要的文本拆分,同时确保@用户名的完整性。

内容的提问来源于stack exchange,提问作者Ahmed Zaidan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 18:17:11