如何在预览参数中实现带有自定义视图的上下文菜单,并可以在 SwiftUI 中进行交互?

问题描述 投票:0回答:1

我必须实现一个具有类似 iMessages 交互的聊天功能。我们决定使用 SwiftUI,因为它应该更快。但现在我在实现反应视图时陷入困境。 ContextMenu 很简单,但是当我希望作为 contextMenu 预览中的参数的反应视图正常工作时,它只会忽略整个 contextMenu 而不会触发操作。

MessageView(
    isMyMessage: element.sender.id == currentUserID,
    senderAvatar: Image(.testUser),
    messageType: element.kind,
    previousMessageFromSameSender: messages[previousIndex].sender.id == element.sender.id
).contextMenu {
    Button(),
    Button(),
    Button()
} preview: {
    VSStack {
        reactionsView // this doesn't interact but it should
        messageView // again the same chat bubble
    }
}

预期结果

swift swiftui contextmenu preview
1个回答
0
投票

正如我在评论中建议的那样,自定义弹出窗口将是实现此目的的一种方法。 iOS SwiftUI Need to Display Popover Without "Arrow" 的答案显示了如何使用

.matchedGeometryEffect
来定位弹出窗口(这是我的答案)。

您提到当弹出窗口显示时您还想要模糊效果。这是可以通过使其依赖于弹出窗口可见性来实现的。您可能还想添加半透明黑色层以提供调光效果。该层可以附加一个点击手势,这样当在后台的任何地方点击时,弹出窗口就会被清除。

这里有一个例子来说明它的工作原理:

struct Message: Identifiable, Equatable {
    let id = UUID()
    let text: String
}

struct MessageView: View {
    let message: Message

    var body: some View {
        Text(message.text)
            .padding(10)
            .background {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.background)
            }
    }
}

struct EmojiButton: View {
    let emoji: Character
    @State private var animate = false

    var body: some View {
        Text(String(emoji))
            .font(.largeTitle)
            .scaleEffect(animate ? 1.3 : 1)
            .onTapGesture {
                print("\(emoji) tapped")
                withAnimation(.bouncy(duration: 0.2, extraBounce: 0.7)) {
                    animate = true
                } completion: {
                    withAnimation(.bouncy(duration: 0.05)) {
                        animate = false
                    }
                }
            }
    }
}

struct Demo: View {
    @State private var selectedMessage: Message?
    @Namespace private var nsPopover

    private let demoMessages: [Message] = [
        Message(text: "Once upon a time"),
        Message(text: "the quick brown fox jumps over the lazy dog"),
        Message(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."),
        Message(text: "and they all lived happily ever after.")
    ]

    private var reactionsView: some View {
        HStack {
            ForEach(Array("👍👎😄🔥💕⚠️❓"), id: \.self) { char in
                EmojiButton(emoji: char)
            }
        }
        .padding(10)
        .background {
            RoundedRectangle(cornerRadius: 10)
                .fill(.bar)
        }
    }

    @ViewBuilder
    private var messageView: some View {
        if let selectedMessage {
            MessageView(message: selectedMessage)
                .allowsHitTesting(false)
        }
    }

    private func optionLabel(label: String, imageName: String) -> some View {
        HStack {
            Text(label)
            Spacer()
            Image(systemName: imageName)
                .resizable()
                .scaledToFit()
                .frame(width: 16, height: 16)
        }
        .padding(.vertical, 10)
        .contentShape(Rectangle())
    }

    private var optionsMenu: some View {
        VStack(alignment: .leading, spacing: 0) {
            Button {
                print("Reply tapped")
            } label: {
                optionLabel(label: "Reply", imageName: "arrowshape.turn.up.left.fill")
            }
            Divider()
            Button {
                print("Copy tapped")
            } label: {
                optionLabel(label: "Copy", imageName: "doc.on.doc.fill")
            }
            Divider()
            Button {
                print("Unsend tapped")
            } label: {
                optionLabel(label: "Unsend", imageName: "location.slash.circle.fill")
            }
        }
        .buttonStyle(.plain)
        .padding(.horizontal, 10)
        .frame(width: 220)
        .background {
            RoundedRectangle(cornerRadius: 10)
                .fill(.bar)
        }
    }

    private var customPopover: some View {
        VStack(alignment: .leading) {
            reactionsView
            messageView
            optionsMenu
        }
        .padding(.top, -70)
        .padding(.trailing)
        .padding(.trailing)
    }

    var body: some View {
        ZStack {
            VStack(alignment: .leading, spacing: 100) {
                ForEach(demoMessages) { message in
                    MessageView(message: message)
                        .matchedGeometryEffect(
                            id: message.id,
                            in: nsPopover,
                            anchor: .topLeading,
                            isSource: true
                        )
                        .onLongPressGesture {
                            selectedMessage = message
                        }
                }
            }
            .blur(radius: selectedMessage == nil ? 0 : 5)
            .padding(.horizontal)
            .frame(maxWidth: .infinity, alignment: .leading)

            if let selectedMessage {
                Color.black
                    .opacity(0.15)
                    .ignoresSafeArea()
                    .onTapGesture { self.selectedMessage = nil }

                customPopover
                    .matchedGeometryEffect(
                        id: selectedMessage.id,
                        in: nsPopover,
                        properties: .position,
                        anchor: .topLeading,
                        isSource: false
                    )
                    .transition(
                        .opacity.combined(with: .scale)
                        .animation(.bouncy(duration: 0.25, extraBounce: 0.2))
                    )
            }
        }
        .animation(.easeInOut(duration: 0.25), value: selectedMessage)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.purple)
    }
}

Animation

您可能遇到的唯一问题是弹出窗口很大,因此当它显示在屏幕顶部或底部的消息上方时,某些弹出窗口内容可能会超出屏幕。但是,我希望消息可以滚动,因此用户只需将消息从顶部或底部移开即可。对于列表中的第一条和最后一条消息,您可能需要添加一些额外的填充,以便为弹出窗口的显示留出空间。当然,如果屏幕顶部和底部有其他内容(例如导航控件),那么这也将有助于腾出空间。

© www.soinside.com 2019 - 2024. All rights reserved.