tame the reply swipe so vertical scrolling stays clean
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

- Raise the swipe dominance threshold to 2.0 and add a 24pt
  activation distance so diagonal timeline scrolls never move bubbles
  or fire haptics, and snap the indicator back when a gesture turns
  vertical mid-drag.
- Cover the stricter thresholds in MessageReplySwipeTests.
This commit is contained in:
wt
2026-08-29 21:01:12 +07:00
parent 1a26651daa
commit 0ef77f7f6a
7 changed files with 61 additions and 25 deletions
+6 -14
View File
@@ -344,7 +344,7 @@
6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = "<group>"; };
60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = "<group>"; };
6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = "<group>"; };
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = "<group>"; };
69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = "<group>"; };
@@ -417,12 +417,12 @@
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = "<group>"; };
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = "<group>"; };
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = "<group>"; };
DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = "<group>"; };
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = "<group>"; };
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
@@ -683,7 +683,7 @@
isa = PBXGroup;
children = (
DF8C531E464C9C4EDE2648C6 /* Luma.app */,
EA54340DD785335237158BEF /* Luma.app */,
EA54340DD785335237158BEF /* LumaMac.app */,
6317FAA29F22A3EA8450208F /* LumaTests.xctest */,
19D4E9404712E8643E31CB93 /* LumaWatch.app */,
);
@@ -767,7 +767,7 @@
2A745A82DD6F34F0CB46B52B /* WebRTC */,
);
productName = LumaMac;
productReference = EA54340DD785335237158BEF /* Luma.app */;
productReference = EA54340DD785335237158BEF /* LumaMac.app */;
productType = "com.apple.product-type.application";
};
BAB78463D7DE07A8F7B3D27D /* LumaWatch */ = {
@@ -813,7 +813,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2660;
LastUpgradeCheck = 1600;
TargetAttributes = {
0565F6C7DA26F0482928704E = {
DevelopmentTeam = K3AS449A74;
@@ -1169,7 +1169,6 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
@@ -1192,7 +1191,6 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
@@ -1290,7 +1288,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1324,7 +1321,6 @@
COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -1348,7 +1344,6 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
@@ -1378,7 +1373,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1412,7 +1406,6 @@
COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -1429,7 +1422,6 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.3">
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -40,7 +41,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -63,6 +65,8 @@
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -84,6 +88,8 @@
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -2,12 +2,19 @@ import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
/// Horizontal travel required before the reply indicator appears. Keeps
/// the per-cell state untouched during ordinary vertical scrolling.
static let activationDistance: CGFloat = 24
static let maximumOffset: CGFloat = 76
static let horizontalDominance: CGFloat = 1.4
/// The drag must be clearly horizontal: a mostly vertical timeline scroll
/// (even a diagonal one) must never arm the reply swipe or move bubbles.
static let horizontalDominance: CGFloat = 2.0
static func offset(for translation: CGSize) -> CGFloat {
guard isHorizontal(translation) else { return 0 }
let distance = min(abs(translation.width) * 0.72, maximumOffset)
let width = abs(translation.width)
guard width >= activationDistance else { return 0 }
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
return translation.width < 0 ? -distance : distance
}
@@ -177,9 +177,16 @@ struct MessageBubble: View {
DragGesture(minimumDistance: 20)
.onChanged { value in
let offset = MessageReplySwipePolicy.offset(for: value.translation)
guard offset != 0 || replySwipeOffset != 0 else {
// A vertical timeline pan must not publish per-cell state
// changes or compete with UIScrollView's own recognizer.
guard offset != 0 else {
// Vertical (or not clearly horizontal) movement: snap the
// indicator back and never publish per-cell state changes
// during an ordinary timeline scroll.
if replySwipeOffset != 0 || replySwipeArmed {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
replySwipeOffset = 0
}
replySwipeArmed = false
}
return
}
replySwipeOffset = offset
+24
View File
@@ -46,6 +46,30 @@ final class MessageReplySwipeTests: XCTestCase {
))
}
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() {
XCTAssertEqual(
MessageReplySwipePolicy.offset(for: CGSize(width: 20, height: 0)),
0
)
XCTAssertGreaterThan(
MessageReplySwipePolicy.offset(for: CGSize(width: 24, height: 0)),
0
)
}
func testVisualOffsetIsCapped() {
XCTAssertEqual(
MessageReplySwipePolicy.offset(for: CGSize(width: 500, height: 0)),