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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.26 21:07:33