make the reply swipe reliable and add a telegram-style plate
- Lock the gesture into a reply swipe once it starts clearly horizontal (1.6 dominance, 12pt) and relax the ratio to 1.05 afterwards, so a slightly diagonal finish no longer cancels the swipe while vertical scrolls stay untouched. - Restyle the composer reply banner as a rounded telegram-like card with the reply header, close button and two-line quote preview. - Cover the lock/follow behaviour in MessageReplySwipeTests.
This commit is contained in:
1 parent
0ef77f7f6a
commit
2130a76735
4 files changed
+462
-169
No files matched your search
@@ -2,16 +2,30 @@ import CoreGraphics
|
|||||||
|
|
||||||
struct MessageReplySwipePolicy {
|
struct MessageReplySwipePolicy {
|
||||||
static let triggerDistance: CGFloat = 64
|
static let triggerDistance: CGFloat = 64
|
||||||
/// Horizontal travel required before the reply indicator appears. Keeps
|
/// Horizontal travel required before the indicator starts following the
|
||||||
/// the per-cell state untouched during ordinary vertical scrolling.
|
/// finger; keeps per-cell state untouched during ordinary scrolling.
|
||||||
static let activationDistance: CGFloat = 24
|
static let activationDistance: CGFloat = 24
|
||||||
static let maximumOffset: CGFloat = 76
|
static let maximumOffset: CGFloat = 76
|
||||||
/// The drag must be clearly horizontal: a mostly vertical timeline scroll
|
/// Ratio required to lock the gesture into a reply swipe. Mostly vertical
|
||||||
/// (even a diagonal one) must never arm the reply swipe or move bubbles.
|
/// timeline scrolls (even diagonal ones) never reach it.
|
||||||
static let horizontalDominance: CGFloat = 2.0
|
static let lockDominance: CGFloat = 1.6
|
||||||
|
/// Relaxed ratio once locked, so the finger can drift while finishing
|
||||||
|
/// the swipe without it being cancelled by the scroll view.
|
||||||
|
static let followDominance: CGFloat = 1.05
|
||||||
|
|
||||||
static func offset(for translation: CGSize) -> CGFloat {
|
/// Whether this translation can lock the gesture as a horizontal swipe.
|
||||||
guard isHorizontal(translation) else { return 0 }
|
static func canLock(_ translation: CGSize) -> Bool {
|
||||||
|
isHorizontal(translation, dominance: lockDominance)
|
||||||
|
&& abs(translation.width) >= activationDistance * 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isHorizontal(_ translation: CGSize) -> Bool {
|
||||||
|
isHorizontal(translation, dominance: lockDominance)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func offset(locked: Bool, translation: CGSize) -> CGFloat {
|
||||||
|
let dominance = locked ? followDominance : lockDominance
|
||||||
|
guard isHorizontal(translation, dominance: dominance) else { return 0 }
|
||||||
let width = abs(translation.width)
|
let width = abs(translation.width)
|
||||||
guard width >= activationDistance else { return 0 }
|
guard width >= activationDistance else { return 0 }
|
||||||
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
|
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
|
||||||
@@ -19,20 +33,24 @@ struct MessageReplySwipePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func shouldReply(
|
static func shouldReply(
|
||||||
|
locked: Bool,
|
||||||
translation: CGSize,
|
translation: CGSize,
|
||||||
predictedEndTranslation: CGSize
|
predictedEndTranslation: CGSize
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
||||||
? predictedEndTranslation
|
? predictedEndTranslation
|
||||||
: translation
|
: translation
|
||||||
return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance
|
let dominance = locked ? followDominance : lockDominance
|
||||||
|
return isHorizontal(candidate, dominance: dominance)
|
||||||
|
&& abs(candidate.width) >= triggerDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
static func progress(for offset: CGFloat) -> CGFloat {
|
static func progress(for offset: CGFloat) -> CGFloat {
|
||||||
min(1, abs(offset) / triggerDistance)
|
min(1, abs(offset) / triggerDistance)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isHorizontal(_ translation: CGSize) -> Bool {
|
private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool {
|
||||||
abs(translation.width) > abs(translation.height) * horizontalDominance
|
abs(translation.width) > abs(translation.height) * dominance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+374
-141
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,7 @@ struct MessageBubble: View {
|
|||||||
|
|
||||||
@State private var replySwipeOffset: CGFloat = 0
|
@State private var replySwipeOffset: CGFloat = 0
|
||||||
@State private var replySwipeArmed = false
|
@State private var replySwipeArmed = false
|
||||||
|
@State private var replySwipeLocked = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -53,6 +54,7 @@ struct MessageBubble: View {
|
|||||||
guard selecting else { return }
|
guard selecting else { return }
|
||||||
replySwipeOffset = 0
|
replySwipeOffset = 0
|
||||||
replySwipeArmed = false
|
replySwipeArmed = false
|
||||||
|
replySwipeLocked = false
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
@@ -176,7 +178,16 @@ struct MessageBubble: View {
|
|||||||
private var replySwipeGesture: some Gesture {
|
private var replySwipeGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 20)
|
DragGesture(minimumDistance: 20)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let offset = MessageReplySwipePolicy.offset(for: value.translation)
|
// Once the gesture is clearly horizontal it locks in, so a
|
||||||
|
// slightly diagonal finish can neither break the reply swipe
|
||||||
|
// nor let the scroll view steal it.
|
||||||
|
if !replySwipeLocked, MessageReplySwipePolicy.canLock(value.translation) {
|
||||||
|
replySwipeLocked = true
|
||||||
|
}
|
||||||
|
let offset = MessageReplySwipePolicy.offset(
|
||||||
|
locked: replySwipeLocked,
|
||||||
|
translation: value.translation
|
||||||
|
)
|
||||||
guard offset != 0 else {
|
guard offset != 0 else {
|
||||||
// Vertical (or not clearly horizontal) movement: snap the
|
// Vertical (or not clearly horizontal) movement: snap the
|
||||||
// indicator back and never publish per-cell state changes
|
// indicator back and never publish per-cell state changes
|
||||||
@@ -191,6 +202,7 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
replySwipeOffset = offset
|
replySwipeOffset = offset
|
||||||
let armed = MessageReplySwipePolicy.shouldReply(
|
let armed = MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: replySwipeLocked,
|
||||||
translation: value.translation,
|
translation: value.translation,
|
||||||
predictedEndTranslation: value.translation
|
predictedEndTranslation: value.translation
|
||||||
)
|
)
|
||||||
@@ -201,9 +213,11 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let shouldReply = MessageReplySwipePolicy.shouldReply(
|
let shouldReply = MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: replySwipeLocked,
|
||||||
translation: value.translation,
|
translation: value.translation,
|
||||||
predictedEndTranslation: value.predictedEndTranslation
|
predictedEndTranslation: value.predictedEndTranslation
|
||||||
)
|
)
|
||||||
|
replySwipeLocked = false
|
||||||
if replySwipeOffset != 0 || replySwipeArmed {
|
if replySwipeOffset != 0 || replySwipeArmed {
|
||||||
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
|
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
|
||||||
replySwipeOffset = 0
|
replySwipeOffset = 0
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import XCTest
|
|||||||
final class MessageReplySwipeTests: XCTestCase {
|
final class MessageReplySwipeTests: XCTestCase {
|
||||||
func testHorizontalSwipeInEitherDirectionReplies() {
|
func testHorizontalSwipeInEitherDirectionReplies() {
|
||||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: 70, height: 8),
|
translation: CGSize(width: 70, height: 8),
|
||||||
predictedEndTranslation: CGSize(width: 72, height: 9)
|
predictedEndTranslation: CGSize(width: 72, height: 9)
|
||||||
))
|
))
|
||||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: -70, height: 8),
|
translation: CGSize(width: -70, height: 8),
|
||||||
predictedEndTranslation: CGSize(width: -72, height: 9)
|
predictedEndTranslation: CGSize(width: -72, height: 9)
|
||||||
))
|
))
|
||||||
@@ -16,6 +18,7 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
|
|
||||||
func testVerticalTimelineScrollDoesNotReply() {
|
func testVerticalTimelineScrollDoesNotReply() {
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: 42, height: 80),
|
translation: CGSize(width: 42, height: 80),
|
||||||
predictedEndTranslation: CGSize(width: 50, height: 105)
|
predictedEndTranslation: CGSize(width: 50, height: 105)
|
||||||
))
|
))
|
||||||
@@ -23,17 +26,33 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
|
|
||||||
func testDiagonalTimelineScrollDoesNotReply() {
|
func testDiagonalTimelineScrollDoesNotReply() {
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: 54, height: 44),
|
translation: CGSize(width: 54, height: 44),
|
||||||
predictedEndTranslation: CGSize(width: 76, height: 58)
|
predictedEndTranslation: CGSize(width: 76, height: 58)
|
||||||
))
|
))
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
MessageReplySwipePolicy.offset(for: CGSize(width: 54, height: 44)),
|
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 54, height: 44)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiagonalScrollAtOldThresholdDoesNotReply() {
|
||||||
|
// width/height ≈ 1.5 passed the old 1.4 dominance and moved bubbles
|
||||||
|
// during scrolling; the stricter policy must stay silent.
|
||||||
|
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
|
translation: CGSize(width: 60, height: 40),
|
||||||
|
predictedEndTranslation: CGSize(width: 60, height: 40)
|
||||||
|
))
|
||||||
|
XCTAssertEqual(
|
||||||
|
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 60, height: 40)),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testShortSwipeDoesNotReply() {
|
func testShortSwipeDoesNotReply() {
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: 36, height: 4),
|
translation: CGSize(width: 36, height: 4),
|
||||||
predictedEndTranslation: CGSize(width: 48, height: 5)
|
predictedEndTranslation: CGSize(width: 48, height: 5)
|
||||||
))
|
))
|
||||||
@@ -41,39 +60,48 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
|
|
||||||
func testFastHorizontalFlickUsesPredictedEnd() {
|
func testFastHorizontalFlickUsesPredictedEnd() {
|
||||||
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: false,
|
||||||
translation: CGSize(width: -35, height: 3),
|
translation: CGSize(width: -35, height: 3),
|
||||||
predictedEndTranslation: CGSize(width: -90, height: 7)
|
predictedEndTranslation: CGSize(width: -90, height: 7)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDiagonalScrollAtOldThresholdDoesNotReply() {
|
|
||||||
// width/height ≈ 1.5 passed the old 1.4 dominance and moved bubbles
|
|
||||||
// during scrolling; the stricter policy must stay silent.
|
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
|
||||||
translation: CGSize(width: 60, height: 40),
|
|
||||||
predictedEndTranslation: CGSize(width: 60, height: 40)
|
|
||||||
))
|
|
||||||
XCTAssertEqual(
|
|
||||||
MessageReplySwipePolicy.offset(for: CGSize(width: 60, height: 40)),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testIndicatorStaysHiddenUntilActivationDistance() {
|
func testIndicatorStaysHiddenUntilActivationDistance() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
MessageReplySwipePolicy.offset(for: CGSize(width: 20, height: 0)),
|
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 20, height: 0)),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
XCTAssertGreaterThan(
|
XCTAssertGreaterThan(
|
||||||
MessageReplySwipePolicy.offset(for: CGSize(width: 24, height: 0)),
|
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 24, height: 0)),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVisualOffsetIsCapped() {
|
func testVisualOffsetIsCapped() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
MessageReplySwipePolicy.offset(for: CGSize(width: 500, height: 0)),
|
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 500, height: 0)),
|
||||||
MessageReplySwipePolicy.maximumOffset
|
MessageReplySwipePolicy.maximumOffset
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCanLockRequiresClearHorizontalStart() {
|
||||||
|
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
|
||||||
|
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 30, height: 20)))
|
||||||
|
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 8, height: 5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLockedSwipeToleratesVerticalDrift() {
|
||||||
|
// Once locked the finger may drift: a 1.17 ratio would be rejected
|
||||||
|
// by the 1.6 lock dominance but must still count as a reply swipe.
|
||||||
|
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: true,
|
||||||
|
translation: CGSize(width: 70, height: 60),
|
||||||
|
predictedEndTranslation: CGSize(width: 70, height: 60)
|
||||||
|
))
|
||||||
|
XCTAssertGreaterThan(
|
||||||
|
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 60)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user