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

SwiftUI中如何让HStack在消息回复场景匹配父视图宽度?

SwiftUI 复刻 WhatsApp 消息回复功能的宽度适配问题

我正在用 SwiftUI 复刻 WhatsApp 的消息回复功能,消息视图最大宽度设为 300。当消息关联了被回复的旧消息时,旧消息会显示在当前消息上方,现在的问题是:我希望旧消息的宽度和当前消息保持一致——旧消息内容比当前消息短时,它的背景要延伸到当前消息的宽度,但又不能把宽度设为 .infinity,否则当前消息右侧会出现大量空白。

现有代码

struct DMMessageBubbleReceiver: View{
    
    let impactMed = UIImpactFeedbackGenerator(style: .medium)
    
    @State var message: Message
    
    @State private var showActionSheet = false
    
    @State var userUID: String
    
    @State var showEmojis = false
    
    @State private var scale: CGFloat = 0.5
    
    @State var emojis = ["😂", "❤️", "👍", "👎", "✈️", "☠️"]
    
    @Binding var blurEverything: String
    
    @ObservedObject var homeData = HomeViewModel.shared
    
    @State private var offsetX: CGFloat = 0
    @Binding var isReplying: Bool
    @Binding var replyToMessage: MsgReply
    
    var body: some View {
        
        VStack(alignment: .leading, spacing: 0) {
            
            if showEmojis {
                
                HStack(spacing: 17) {
                    
                    ForEach(emojis, id: \.self) { emoji in
                        Button(action: {
                            impactMed.impactOccurred()
                            withAnimation {
                                showEmojis.toggle()
                                blurEverything = ""
                            }
                            homeData.addRemoveEmoji(message: message, emoji: emoji, userUID: userUID)
                        }) {
                            Text(emoji)
                                .font(.custom("Poppins-Regular", fixedSize: 18))
                        }
                    }
                }
                .padding(.horizontal).padding(.vertical, 5)
                .background(Color(UIColor.systemGray5).cornerRadius(50))
                .padding(.bottom, 5)
                .scaleEffect(scale)
                .onAppear {
                    withAnimation(.easeInOut(duration: 0.3)) {
                        scale = 1.2
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            impactMed.impactOccurred()
                            withAnimation(.easeInOut(duration: 0.3)) {
                                scale = 1.0
                            }
                        }
                    }
                }
            }
            
            HStack {
                
                HStack {
                    
                    if message.deleted != true {
                        
                        ZStack(alignment: .bottomTrailing) {
                            
                            VStack(alignment: .leading, spacing: 0){
                                
                                if let reply = message.reply{
                                    
                                    HStack{
                                        
                                        VStack(alignment: .leading, spacing: 5){
                                            
                                            Text("You")
                                                .font(.custom("Gilroy-Medium", fixedSize: 12))
                                                .foregroundColor(.blue)
                                                .lineSpacing(5)
                                            
                                            Text(reply.msg)
                                                .font(.custom("Gilroy-Medium", fixedSize: 13))
                                                .foregroundColor(.black)
                                                .lineSpacing(5)
                                                .lineLimit(3)
                                            
                                        }
                                        
                                    }.padding(10).background(Color.blue.opacity(0.07).cornerRadius(5)).padding(.horizontal, 5).padding(.top, 5)
                                    
                                }
                                
                                Text(filterLinksInText(message.text))
                                    .font(.custom("Gilroy-Medium", fixedSize: 15))
                                    .foregroundColor(.black)
                                    .lineSpacing(5)
                                    .padding(.vertical, 14).padding(.leading, 10).padding(.trailing, 15)
                                    
                                
                            }
                            .background(Color(UIColor.systemGray5).opacity(0.6))
                            .cornerRadius(12, corners: [.topLeft, .topRight, .bottomRight])
                            .offset(x: offsetX)
                            .gesture(
                                DragGesture()
                                    .onChanged { value in
                                        if message.deleted == false {
                                            if value.translation.width > 0 {
                                                offsetX = value.translation.width
                                            }
                                        }
                                    }
                                    .onEnded { value in
                                        if offsetX > 100 {
                                            
                                            impactMed.impactOccurred()
                                            
                                            withAnimation {
                                                isReplying = true
                                                replyToMessage = MsgReply(id: message.id, msg: message.text, name: "name")
                                                offsetX = 0
                                            }
                                        } else {
                                            withAnimation {
                                                offsetX = 0
                                            }
                                        }
                                    }
                            )
                            
                            if message.emoji != "" {
                                Text(message.emoji)
                                    .font(.system(size: 12))
                                    .foregroundColor(.red)
                                    .padding(3)
                                    .background(Color("messagebackground"))
                                    .cornerRadius(50)
                                    .offset(y: 8)
                                    .offset(x: 8)
                            }
                        }
                    } else {
                        Text("This message was deleted")
                            .font(Font.system(size: 13))
                            .italic()
                            .fontWeight(.medium)
                            .foregroundColor(.black.opacity(0.6))
                            .padding(.horizontal)
                            .padding(.vertical, 10)
                            .background(Color(UIColor.systemGray5))
                            .cornerRadius(12, corners: [.topLeft, .topRight, .bottomRight])
                    }
                }
                .frame(maxWidth: 300, alignment: .leading)

                Spacer()
                
                ZStack(alignment: .bottomTrailing) {
                    Text(" ")
                }.padding(.trailing)
                
            }
            
            Text("\(message.timestamp.formatted(.dateTime.hour().minute()))")
                .font(.custom("Gilroy-Medium", fixedSize: 11))
                .foregroundColor(.black.opacity(0.3))
                .padding(.leading, 5).padding(.top, 8).padding(.bottom, 10)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.leading)
        .padding(.horizontal, 10)
        .blur(radius: (blurEverything != "" && blurEverything != message.id) ? 2 : 0)
        .onTapGesture(count: 2) {
            if message.deleted != true{
                self.likeMessage(message: message)
            }
        }
        .onTapGesture(count: 1) {
            if message.deleted == false {
                withAnimation {
                    showEmojis.toggle()
                    if blurEverything != "" {
                        blurEverything = ""
                    } else {
                        blurEverything = message.id
                    }
                }
            }
        }
        .onLongPressGesture {
            showActionSheet = true
        }
        .actionSheet(isPresented: $showActionSheet) {
            ActionSheet(title: Text("Message Options"), buttons: getButtons(message: message))
        }
    }
    
    func getButtons(message: Message) -> [ActionSheet.Button] {
        var buttons: [ActionSheet.Button] = []
        
        if message.deleted != true {
            buttons.append(.default(Text("Copy"), action: {
                self.copyText(message: message.text)
            }))
        }
        
        if message.deleted != true {
            if message.numLikes > 0 {
                buttons.append(.default(Text("Unlike"), action: { self.likeMessage(message: message) }))
            } else {
                buttons.append(.default(Text("Like"), action: { self.likeMessage(message: message) }))
            }
        }

        if message.deleted == false {
            buttons.append(.destructive(Text("Delete"), action: {
                self.deleteMessage()
            }))
        }

        buttons.append(.cancel(Text("Cancel")))

        return buttons
    }

    func copyText(message: String) {
        UIPasteboard.general.string = message
    }

    func likeMessage(message: Message) {
        impactMed.impactOccurred()
        homeData.likeDMMessage(message: message, userUID: userUID)
    }
    
    func deleteMessage() {
        impactMed.impactOccurred()
        homeData.deleteDMMessage(userUID: userUID, message: message)
    }
    
    func filterLinksInText(_ text: String) -> String {
        do {
            let regexPattern = try NSRegularExpression(pattern: "https?://([-\\w\\.]+)+(:\\d+)?(/([\\w/_\\.]*(\\?\\S+)?)?)?", options: .caseInsensitive)
            let modifiedText = regexPattern.stringByReplacingMatches(
                in: text,
                options: [],
                range: NSRange(location: 0, length: text.utf16.count),
                withTemplate: "****"
            )
            return modifiedText
        } catch {
            return text
        }
    }
}

解决方案

核心思路是让回复消息的容器匹配当前消息的实际渲染宽度,同时保留最大宽度300的限制,避免右侧空白。具体实现步骤如下:

1. 定义偏好键捕获当前消息宽度

通过PreferenceKey捕获当前消息文本的实际宽度:

struct MessageWidthPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

2. 添加状态变量存储宽度

在DMMessageBubbleReceiver结构体中添加状态变量,用于保存捕获到的消息宽度:

@State private var messageWidth: CGFloat = 0

3. 修改消息气泡结构,绑定宽度

调整回复消息和当前消息的布局,让回复消息容器继承当前消息的实际宽度:

// 替换原代码中ZStack内的VStack部分
VStack(alignment: .leading, spacing: 0){
    if let reply = message.reply{
        HStack{
            VStack(alignment: .leading, spacing: 5){
                Text("You")
                    .font(.custom("Gilroy-Medium", fixedSize: 12))
                    .foregroundColor(.blue)
                    .lineSpacing(5)
                
                Text(reply.msg)
                    .font(.custom("Gilroy-Medium", fixedSize: 13))
                    .foregroundColor(.black)
                    .lineSpacing(5)
                    .lineLimit(3)
            }
            .frame(maxWidth: .infinity, alignment: .leading) // 内容左对齐,背景填充父容器
        }
        .padding(10)
        .background(Color.blue.opacity(0.07).cornerRadius(5))
        .padding(.horizontal, 5)
        .padding(.top, 5)
        .frame(width: messageWidth, alignment: .leading) // 绑定到当前消息宽度
    }
    
    Text(filterLinksInText(message.text))
        .font(.custom("Gilroy-Medium", fixedSize: 15))
        .foregroundColor(.black)
        .lineSpacing(5)
        .padding(.vertical, 14)
        .padding(.leading, 10)
        .padding(.trailing, 15)
        .background(Color(UIColor.systemGray5).opacity(0.6))
        .cornerRadius(12, corners: [.topLeft, .topRight, .bottomRight])
        .background(
            // 捕获当前消息的实际宽度,限制最大300
            GeometryReader { proxy in
                Color.clear.preference(
                    key: MessageWidthPreferenceKey.self,
                    value: min(proxy.size.width, 300)
                )
            }
        )
}
.onPreferenceChange(MessageWidthPreferenceKey.self) { newWidth in
    messageWidth = newWidth
}

4. 调整外层容器的宽度限制

确保外层的HStack最大宽度限制保留,避免整体超出300:

// 原代码中的这个frame保留,确保整个气泡最大宽度300
.frame(maxWidth: 300, alignment: .leading)

这样修改后,回复消息的背景会自动延伸到当前消息的实际宽度,既不会因为内容过短而留白,也不会突破300的最大宽度限制,完全贴合 WhatsApp 的效果。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 13:04:54