diff --git a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift index 2cb457acf..ed84c7dfa 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift @@ -28,10 +28,7 @@ struct SwipeRightAction: ViewModifier { let action: () -> Void func body(content: Content) -> some View { - content - .offset(x: xOffset, y: 0.0) - .animation(.interactiveSpring().speed(0.5), value: xOffset) - .timelineGesture(gesture) + mainContent(content: content) .onChange(of: dragGestureActive) { _, newValue in if newValue == true { if shouldStartAction() { @@ -51,7 +48,65 @@ struct SwipeRightAction: ViewModifier { } } - private var gesture: some Gesture { + @ViewBuilder + private func mainContent(content: Content) -> some View { + if #available(iOS 18, *) { + content + .offset(x: xOffset, y: 0.0) + .animation(.interactiveSpring().speed(0.5), value: xOffset) + .gesture(PanGesture { gesture in + switch gesture.state { + case .ended, .cancelled, .failed: + if xOffset > actionThreshold { + action() + } + + xOffset = 0.0 + default: + guard shouldStartAction() else { + return + } + let translation = gesture.translation(in: nil) + + // Due to https://forums.developer.apple.com/forums/thread/760035 we had to make + // the drag a simultaneous gesture otherwise it was impossible to scroll the timeline. + // Therefore we need to prevent the animation to run if the user is to scrolling vertically. + // It would be nice if we could somehow abort the gesture in this case. + let width: CGFloat = if translation.x > abs(translation.y) { + translation.x + } else { + 0.0 + } + + // We want to add a spring like behaviour to the drag in which the view + // moves slower the more it's dragged. We use a circular easing function + // to generate those values up to the `swipeThreshold` + // The final translation will be between 0 and `swipeThreshold` with the action being enabled from + // `actionThreshold` onwards + let screenWidthNormalisedTranslation = max(0.0, min(width, swipeThreshold)) / swipeThreshold + let easedTranslation = circularEaseOut(screenWidthNormalisedTranslation) + xOffset = easedTranslation * xOffsetThreshold + + if xOffset > actionThreshold { + if !hasReachedActionThreshold { + feedbackGenerator.impactOccurred() + hasReachedActionThreshold = true + } + } else { + hasReachedActionThreshold = false + } + + } + }) + } else { + content + .offset(x: xOffset, y: 0.0) + .animation(.interactiveSpring().speed(0.5), value: xOffset) + .gesture(oldGesture) + } + } + + private var oldGesture: some Gesture { DragGesture() .updating($dragGestureActive) { _, state, _ in // Available actions should be computed on the fly so we use a gesture state change @@ -113,18 +168,6 @@ extension View { action: @escaping () -> Void) -> some View { modifier(SwipeRightAction(label: label, shouldStartAction: shouldStartAction, action: action)) } - - @ViewBuilder - fileprivate func timelineGesture(_ gesture: some Gesture) -> some View { - if #available(iOS 18.0, *) { - // iOS 18 has a bug https://forums.developer.apple.com/forums/thread/760035 and you - // can't scroll the timeline when `gesture` is used. - simultaneousGesture(gesture) - } else { - // Equally on iOS 17 you can't scroll the timeline when `simultaneousGesture` is used. - self.gesture(gesture) - } - } } struct SwipeRightAction_Previews: PreviewProvider, TestablePreview { @@ -159,3 +202,38 @@ struct SwipeRightAction_Previews: PreviewProvider, TestablePreview { } } } + +// Fixes the issue on iOS 18 where DragGesture conflicts with the scroll view +// https://github.com/feedback-assistant/reports/issues/542#issuecomment-2581322968 +private struct PanGesture: UIGestureRecognizerRepresentable { + var handle: (UIPanGestureRecognizer) -> Void + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { .init() } + + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let gesture = UIPanGestureRecognizer() + gesture.delegate = context.coordinator + gesture.isEnabled = true + return gesture + } + + func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context: Context) { + handle(recognizer) + } + + class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { return false } + + let velocity = panRecognizer.velocity(in: gestureRecognizer.view) + return abs(velocity.y) < abs(velocity.x) + } + } +}