SwiftUI自定义@提及文本视图换行异常问题及方案咨询
问题描述
为实现@提及内容点击跳转至用户档案,我重构了文本视图确保所有含@的内容可点击。多数情况显示正常,但当单词接近水平截断位置时,会出现原生文本视图不会发生的提前换行问题——我通过在底层放置透明同样式文本控制高度,确认原生视图不会如此。提前换行会导致后续评论与该TextWithMentions视图重叠。最初遇到的是单行仅容下一个字符仍不换行的问题,添加修复代码后引发了当前的提前换行问题。
此实现方式繁琐,寻求更优方案,核心需求如下:
- 文本视图的占用空间与换行逻辑需与原生文本完全一致,视觉表现相同;
- 可识别@提及内容并支持点击跳转至对应用户档案,且支持单条评论中多个@提及。
原实现代码
TextWithMentions及相关结构
struct TextWithMentions: View { let commentText: String @Binding var needsDismissed: Bool @State private var mentionedUsers: [User] = [] @State private var sizes: [CGSize] = [] @EnvironmentObject var navigationManager: NavigationManager @State private var selection: String? = nil var body: some View { MultilineHStack(views: extractMentions(from: commentText).map { component in buildTextView(for: component) }) .task { fetchMentionedUsers() } } private func buildTextView(for component: Component) -> AnyView { switch component { case .normal(let text): return AnyView( Text("\(text) ") .font(Font.caption) ) case .mention(let username, let user): return AnyView( Text("@\(username) ") .foregroundColor(Color.accentColor) .font(Font.caption) .onTapGesture { if let user = user { navigationManager.goTo(destination: .profile(user)) needsDismissed = true selection = username } } ) case .mentionPlaceholder(let username): return AnyView( Text("@\(username) ") .font(Font.caption) ) } } private func extractMentions(from text: String) -> [Component] { let words = text.components(separatedBy: .whitespacesAndNewlines) var components: [Component] = [] for word in words { if word.hasPrefix("@") { let username = String(word.dropFirst()) if let user = mentionedUsers.first(where: { $0.username == username }) { components.append(.mention(username: username, user: user)) } else { components.append(.mentionPlaceholder(username: username)) } } else { components.append(.normal(text: word)) } } return components } private func fetchMentionedUsers() { let usernames = mentionedUsernames(from: commentText) print("usernames: \(usernames)") if !usernames.isEmpty { fetchMentionedUsers(usernames: usernames) { fetchedUsers in mentionedUsers = fetchedUsers } } } private func mentionedUsernames(from text: String) -> [String] { let words = text.components(separatedBy: .whitespacesAndNewlines) return words.filter { $0.hasPrefix("@") }.map { String($0.dropFirst()) } } private func fetchMentionedUsers(usernames: [String], completion: @escaping ([User]) -> Void) { let query = COLLECTION_USERS.whereField("username", in: usernames) query.getDocuments { snapshot, error in if let error = error { print("Error fetching mentioned users: \(error)") completion([]) return } let mentionedUsers = snapshot?.documents.compactMap { try? $0.data(as: User.self) } ?? [] print("Users fetched: \(mentionedUsers)") completion(mentionedUsers) } } } enum Component { case normal(text: String) case mention(username: String, user: User?) case mentionPlaceholder(username: String) } extension View { func eraseToAnyView() -> AnyView { AnyView(self) } } public struct MultilineHStack: View { struct SizePreferenceKey: PreferenceKey { typealias Value = [CGSize] static var defaultValue: Value = [] static func reduce(value: inout Value, nextValue: () -> Value) { value.append(contentsOf: nextValue()) } } private let views: [AnyView] @State private var sizes: [CGSize] = [] public init(views: [AnyView]) { self.views = views } public var body: some View { GeometryReader { geometry in VStack(alignment: .leading) { ZStack(alignment: .topLeading) { ForEach(self.views.indices) { index in self.views[index].background(self.backgroundView(index: index)) .offset(self.getOffset(at: index, availableWidth: geometry.size.width)) } } .onPreferenceChange(SizePreferenceKey.self) { self.sizes = $0 } } } } private func backgroundView(index: Int) -> some View { GeometryReader { geometry in Rectangle() .fill(Color.clear) .preference( key: SizePreferenceKey.self, value: [geometry.size] ) } } private func calculateTotalHeight() -> CGFloat { let totalHeight = sizes.map { $0.height }.reduce(0, +) + CGFloat(sizes.count - 1) * 4 return totalHeight } private func getOffset(at index: Int, availableWidth: CGFloat) -> CGSize { guard index < sizes.endIndex else { return .zero } var (x, y) = sizes[..<index].reduce((CGFloat.zero, CGFloat.zero)) { (currentOffset, size) in let (currentX, currentY) = currentOffset if currentX + size.width <= availableWidth { return (currentX + size.width, currentY) } else { return (size.width, currentY + size.height) } } let wordWidth = sizes[index].width if x + wordWidth > availableWidth { x = 0 y += sizes[index].height for i in index..<sizes.endIndex { sizes[i].height += sizes[index].height } } return CGSize(width: x, height: y) } private func calculateMaxWidth(availableWidth: CGFloat) -> CGFloat { return availableWidth } }
CommentCell
struct CommentCell: View { let commentText = "@john I need to go to the store soon with @jane" @State private var needsDismissed = false var hasMentions: Bool { return commentText.contains("@") } var body: some View { if self.hasMentions { Text(commentText) .foregroundStyle(Color.clear) .multilineTextAlignment(.leading) .font(Font.caption) .overlay( TextWithMentions(commentText: commentText, needsDismissed: $needsDismissed) ) } else { Text(commentText) .font(Font.caption) .multilineTextAlignment(.leading) } } }
解决方案:基于UITextView的UIViewRepresentable实现
该方案复用UIKit原生UITextView的布局逻辑,完全匹配原生文本的换行和空间占用,同时支持@提及的精准点击。
1. 定义基础数据结构和回调
struct Mention { let username: String let user: User? let range: NSRange } typealias MentionTapHandler = (User) -> Void
2. 实现带提及点击的TextView
struct MentionTextView: UIViewRepresentable { let text: String let font: UIFont let textColor: UIColor let mentionTapHandler: MentionTapHandler @State private var mentions: [Mention] = [] func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.font = font textView.textColor = textColor textView.isEditable = false textView.isScrollEnabled = false textView.textContainer.lineBreakMode = .byWordWrapping textView.delegate = context.coordinator return textView } func updateUIView(_ uiView: UITextView, context: Context) { let attributedString = NSMutableAttributedString(string: text) attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: text.count)) attributedString.addAttribute(.foregroundColor, value: textColor, range: NSRange(location: 0, length: text.count)) mentions = extractMentions(from: text) for mention in mentions { attributedString.addAttribute(.foregroundColor, value: UIColor.accentColor, range: mention.range) attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: mention.range) } uiView.attributedText = attributedString } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UITextViewDelegate { var parent: MentionTextView init(_ parent: MentionTextView) { self.parent = parent } func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { return false } func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { return false } func textView(_ textView: UITextView, didTapAt point: CGPoint) { guard let position = textView.closestPosition(to: point), let range = textView.tokenizer.rangeEnclosingPosition(position, with: .word, inDirection: .layout(.left)) else { return } let startIndex = textView.offset(from: textView.beginningOfDocument, to: range.start) let length = textView.offset(from: range.start, to: range.end) let tappedRange = NSRange(location: startIndex, length: length) if let mention = parent.mentions.first(where: { NSEqualRanges($0.range, tappedRange) }), let user = mention.user { parent.mentionTapHandler(user) } } } private func extractMentions(from text: String) -> [Mention] { var mentions: [Mention] = [] let pattern = "@(\\w+)" guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return mentions } let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) for match in matches { let usernameRange = match.range(at: 1) guard let username = (text as NSString).substring(with: usernameRange) as String? else { continue } mentions.append(Mention(username: username, user: nil, range: match.range)) } return mentions } mutating func updateMentions(with users: [User]) { mentions = mentions.map { mention in let matchedUser = users.first(where: { $0.username == mention.username }) return Mention(username: mention.username, user: matchedUser, range: mention.range) } } }
3. 重构TextWithMentions视图
struct TextWithMentions: View { let commentText: String @Binding var needsDismissed: Bool @EnvironmentObject var navigationManager: NavigationManager @State private var mentionedUsers: [User] = [] @State private var mentionTextView = MentionTextView( text: "", font: .caption, textColor: .label, mentionTapHandler: { _ in } ) var body: some View { mentionTextView .onAppear { mentionTextView = MentionTextView( text: commentText, font: .systemFont(ofSize: UIFont.captionSize), textColor: .label, mentionTapHandler: { user in navigationManager.goTo(destination: .profile(user)) needsDismissed = true } ) } .task { fetchMentionedUsers() } .onChange(of: mentionedUsers) { newUsers in mentionTextView.updateMentions(with: newUsers) } } private func fetchMentionedUsers() { let usernames = mentionedUsernames(from: commentText) print("usernames: \(usernames)") if !usernames.isEmpty { fetchMentionedUsers(usernames: usernames) { fetchedUsers in mentionedUsers = fetchedUsers } } } private func mentionedUsernames(from text: String) -> [String] { let pattern = "@(\\w+)" guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) return matches.compactMap { (text as NSString).substring(with: $0.range(at: 1)) as String? } } private func fetchMentionedUsers(usernames: [String], completion: @escaping ([User]) -> Void) { let query = COLLECTION_USERS.whereField("username", in: usernames) query.getDocuments { snapshot, error in if let error = error { print("Error fetching mentioned users: \(error)") completion([]) return } let mentionedUsers = snapshot?.documents.compactMap { try? $0.data(as: User.self) } ?? [] print("Users fetched: \(mentionedUsers)") completion(mentionedUsers) } } }
4. 简化CommentCell
struct CommentCell: View { let commentText = "@john I need to go to the store soon with @jane" @State private var needsDismissed = false var hasMentions: Bool { return commentText.contains("@") } var body: some View { Group { if hasMentions { TextWithMentions(commentText: commentText, needsDismissed: $needsDismissed) .font(.caption) } else { Text(commentText) .font(.caption) .multilineTextAlignment(.leading) } } } }
方案优势
- 完全复用
UITextView原生布局逻辑,与原生Text的换行、空间表现完全一致; - 无需手动计算文本位置和偏移,彻底避免自定义布局带来的换行错误;
- 支持多个@提及的精准点击识别,交互体验流畅;
- 代码结构更简洁,后续维护成本更低。
内容的提问来源于stack exchange,提问作者amelia
相关产品推荐
相关产品推荐

