eleven / ocho Public

ios: swipe-right to reply on mobile message rows (#11)

Mobile (touch) had no swipe affordance for replying — only the long-press context menu — while pointer devices get the hover button. Add a WhatsApp/Messages-style swipe-right-to-reply on the message row, mirroring the web (attachSwipeReply in chat.js): 56pt trigger, 80pt cap, a reply arrow that fades in on the leading edge, and a medium haptic on fire.

Gated out of the Mac Catalyst build with #if !targetEnvironment(macCatalyst). Uses .simultaneousGesture plus a horizontal-dominance lock so it coexists with the feed's vertical scroll and the left-edge back-swipe rather than fighting them.
James Adam 75b66e28b3d07dcc3277f5e0e1d41b2adb11cd13 parent 4c2a91d
1 file changed, +62 −5
  • ios/LChat/Views/MessageRow.swift +62 −5
diff --git a/ios/LChat/Views/MessageRow.swift b/ios/LChat/Views/MessageRow.swift
index 0b9cce4..63c9582 100644
--- a/ios/LChat/Views/MessageRow.swift
+++ b/ios/LChat/Views/MessageRow.swift
@@ -12,17 +12,74 @@ struct MessageRow: View {
@State private var hovering = false
var body: some View {
- HStack(spacing: 6) {
- if msg.mine { Spacer(minLength: 28) }
- column.contextMenu { reactionMenu }
- if !msg.mine { Spacer(minLength: 28) }
- }
+ swipeToReply(
+ HStack(spacing: 6) {
+ if msg.mine { Spacer(minLength: 28) }
+ column.contextMenu { reactionMenu }
+ if !msg.mine { Spacer(minLength: 28) }
+ }
+ )
.padding(flashing ? 6 : 0)
.background(flashing ? Color.yellow.opacity(0.3) : .clear, in: RoundedRectangle(cornerRadius: 12))
.animation(.easeOut(duration: 0.3), value: flashing)
.onHover { hovering = $0 }
}
+ // MARK: Swipe to reply (touch)
+
+ // Mobile (touch) gets the WhatsApp/Messages affordance: drag a message to the
+ // right to reply. Pointer devices use the hover button above and Mac has no
+ // touch, so this is gated out of the Mac Catalyst build entirely. Mirrors the
+ // web (attachSwipeReply in chat.js): a 56pt trigger, 80pt cap, and a
+ // horizontal-dominant lock so it never steals the feed's vertical scroll.
+#if targetEnvironment(macCatalyst)
+ private func swipeToReply<Content: View>(_ content: Content) -> some View { content }
+#else
+ @State private var dragOffset: CGFloat = 0
+ @State private var swipeEngaged: Bool?
+
+ private static let swipeTrigger: CGFloat = 56
+ private static let swipeMax: CGFloat = 80
+
+ private func swipeToReply<Content: View>(_ content: Content) -> some View {
+ // Offset first, then lay the reply arrow in the *background*: the arrow
+ // stays pinned to the leading edge while the message slides off it.
+ content
+ .offset(x: dragOffset)
+ .background(alignment: .leading) {
+ Image(systemName: "arrowshape.turn.up.left.fill")
+ .foregroundStyle(.tint)
+ .padding(.leading, 12)
+ .opacity(Double(min(1, dragOffset / Self.swipeTrigger)))
+ }
+ .simultaneousGesture(
+ // Simultaneous (not exclusive) so the ScrollView still owns
+ // vertical drags; we only act on ones that read as a rightward swipe.
+ DragGesture(minimumDistance: 10)
+ .onChanged { value in
+ let dx = value.translation.width
+ let dy = value.translation.height
+ if swipeEngaged == nil {
+ swipeEngaged = dx > 0 && abs(dx) > abs(dy) * 1.3
+ }
+ guard swipeEngaged == true else { return }
+ dragOffset = min(max(0, dx), Self.swipeMax)
+ }
+ .onEnded { _ in
+ let fire = dragOffset >= Self.swipeTrigger
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
+ dragOffset = 0
+ }
+ swipeEngaged = nil
+ if fire {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ onReply(msg)
+ }
+ }
+ )
+ }
+#endif
+
private var column: some View {
VStack(alignment: msg.mine ? .trailing : .leading, spacing: 3) {
if !msg.mine {