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