Files
Luma/Sources/Shared/Models/MessageReplySwipePolicy.swift
T
wt 5a7fc36722
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
stop the disconnected-history alert from blocking scrolling
- Skip older-history loads silently while the connection is down:
  the trigger previously failed with "not connected", popped the
  global alert over the timeline and froze scrolling until dismissed.
- Accept fast reply-swipe flicks whose end state is clearly leftward
  even without an intermediate horizontal lock, while scroll-owned
  touches still never activate the swipe.
- Add a seeded-chat UI test mode (launch argument), accessibility
  identifiers and a LumaUITests target with timeline scroll and reply
  swipe tests; guard the fix in verify.sh.
2026-08-30 00:09:05 +07:00

65 lines
2.7 KiB
Swift

import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
/// Horizontal travel required before the indicator starts following the
/// finger; keeps per-cell state untouched during ordinary scrolling.
static let activationDistance: CGFloat = 24
static let maximumOffset: CGFloat = 76
/// Ratio required to lock the gesture into a reply swipe. Mostly vertical
/// timeline scrolls (even diagonal ones) never reach it.
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
/// Whether this translation can lock the gesture as a horizontal swipe.
/// Only right-to-left (swipe left, negative width) is accepted, and the
/// lock is only available early: once the finger has travelled mostly
/// vertically the scroll view owns the touch and the swipe must never
/// activate for the rest of that gesture.
static func canLock(_ translation: CGSize) -> Bool {
translation.width < 0
&& abs(translation.height) <= 24
&& 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 {
guard translation.width < 0 else { return 0 }
let dominance = locked ? followDominance : lockDominance
guard isHorizontal(translation, dominance: dominance) else { return 0 }
let width = abs(translation.width)
guard width >= activationDistance else { return 0 }
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
return -distance
}
static func shouldReply(
locked: Bool,
translation: CGSize,
predictedEndTranslation: CGSize
) -> Bool {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
guard candidate.width < 0 else { return false }
let dominance = locked ? followDominance : lockDominance
return isHorizontal(candidate, dominance: dominance)
&& abs(candidate.width) >= triggerDistance
}
static func progress(for offset: CGFloat) -> CGFloat {
min(1, abs(offset) / triggerDistance)
}
private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool {
abs(translation.width) > abs(translation.height) * dominance
}
}