2

I've got to implement a chat feature with similar interactions like in iMessages. We decided to use SwiftUI because it should be faster. But now I am stuck when implementing the reactions view. ContextMenu is easy, but when I want the reactions view that is a parameter in contextMenu preview to work it just dismisses the whole contextMenu without triggering the action.

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
    }
}

Expected result

expected result

2
  • You could always use a custom popover instead, then you can show any content you like and it can also be interactive. The answer to iOS SwiftUI Need to Display Popover Without "Arrow" shows a way of doing this. Commented Apr 5, 2024 at 9:50
  • @BenzyNeez but then you are not able to add the blur background :/ Commented Apr 9, 2024 at 5:29

1 Answer 1

6

As I was suggesting in a comment, a custom popover would be a way of implementing this. The answer to iOS SwiftUI Need to Display Popover Without "Arrow" shows how .matchedGeometryEffect can be used to position the popover (it was my answer).

You mentioned that you also want a blur effect when the popover is showing. This is possible by making it dependent on the popover visibility. You might also want to add a semi-transparent black layer to provide a dimming effect. This layer could have a tap gesture attached to it, so that the popover is cleared when a tap is made anywhere in the background.

Here is an example to illustrate it working:

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)
            .phaseAnimator([false, true], trigger: animate) { content, phase in
                content.scaleEffect(phase ? 1.3 : 1)
            } animation: { phase in
                .bouncy(duration: phase ? 0.2 : 0.05, extraBounce: phase ? 0.7 : 0)
            }
            .onTapGesture {
                print("\(emoji) tapped")
                animate.toggle()
            }
    }
}

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(spacing: 0) {
            Text(label)
            Spacer()
            Image(systemName: imageName)
                .resizable()
                .scaledToFit()
                .frame(width: 16, height: 16)
        }
        .padding(.vertical, 10)
        .padding(.horizontal)
        .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)
        .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(Color(red: 0.31, green: 0.15, blue: 0.78))
    }
}

Animation

Another way to achieve the blur effect would be to use a Material layer in the background. I tried this too, but even with .ultraThinMaterial I found the blur was very heavy.

One issue you might have is that the popover is large, so when it is shown over a message that is at the top or bottom of the screen, some of the popover content may be off-screen. However, I expect the messages can be scrolled, so the user would just need to move the message away from the top or bottom. For the first and last message in the list, you might want to add some extra padding, to allow space for the popover to show. Of course, if you have other content at top and bottom of screen (like navigation controls) then this will help to make space too.

Sign up to request clarification or add additional context in comments.

5 Comments

Here's a weird bug: if you replace the VStack (the one containing the messages) with a List, then on the 'collapse' animation, the popup vanishes instead of animating out. Actually, if you look closely, it gets pushed behind the list (and still animates). Somehow List is causing mischief... solution: put the popup into an overlay.
@Colin Yes, you don't have much control over animations when content is added or removed from a List, including when sections are expanded/collapsed - see also Broken animation when using custom DisclosureGroup in a SwiftUI List. However, if you are experiencing issues when the content of the List is unchanged then you might like to create a new post for it.
Another problem is that when implement this in a deep view heirarchy, the semi transparent background won't be able to cover the navigationbar or tabbar
@LiLiKazine Yes, if you go for the ZStack approach then the ZStack needs to be the top-level parent, below which is the NavigationStack or the TabView. A different approach might be to use a .fullScreenCover instead, but then you probably won't be able to use .matchedGeometryEffect for positioning. In any case, if you are having difficulties with a particular issue then you might like to create a new post for it.
I actually have tried approaches such as fullscreencover or sheet, sadly either of them would break up the connection of two macthed views

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.