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 }
关键修改点说明
- 组件可换行标记:给
Component枚举添加isLineBreakable属性,标记文本组件为可换行,链接和@用户名为不可换行;同时实现RawRepresentable协议,用于在Layout中识别组件类型。 - 文本组件布局优化:给Text组件添加
.frame(maxWidth: .infinity, alignment: .leading),允许其根据可用空间自动换行。 - 自定义Layout逻辑重构:
sizeThatFits:针对可换行组件动态计算可用宽度,避免强行换行;不可换行组件则判断是否能加入当前行,否则换行,准确计算总高度。placeSubviews:根据组件类型动态分配宽度,文本组件使用剩余空间,确保不同类型组件能在同一行合理排列,仅对不可换行组件触发换行逻辑。
- 文本拆分逻辑优化:修正
separateTextAndTags的空格处理,避免不必要的文本拆分,同时确保@用户名的完整性。
内容的提问来源于stack exchange,提问作者Ahmed Zaidan
相关产品推荐
相关产品推荐

