// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. // import SwiftUI struct SwipeRightAction: ViewModifier { private let actionThreshold = 50.0 private let xOffsetThreshold = 100.0 private let swipeThreshold = 1000.0 private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var canStartAction = false @GestureState private var dragGestureActive = false @State private var hasReachedActionThreshold = false @State private var xOffset = 0.0 /// The view to be shown on the left side of the content let label: () -> Label /// Defer computing whether an action is available until the gesture is started let shouldStartAction: () -> Bool /// Callback for when the dragged past the action threshold let action: () -> Void func body(content: Content) -> some View { mainContent(content: content) .onChange(of: dragGestureActive) { _, newValue in if newValue == true { if shouldStartAction() { feedbackGenerator.prepare() canStartAction = true } } } .overlay(alignment: .leading) { // We want the action icon to follow the view translation and gradually fade in label() .multilineTextAlignment(.center) .frame(maxWidth: actionThreshold) .opacity(xOffset / 50) .animation(.interactiveSpring().speed(0.5), value: xOffset) .offset(x: -actionThreshold + min(xOffset, actionThreshold), y: 0.0) } } private func mainContent(content: Content) -> some View { content .offset(x: xOffset, y: 0.0) .animation(.interactiveSpring().speed(0.5), value: xOffset) .gesture(PanGestureRepresentable { 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 } } }) } 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 // to ask whether the move should be started or not. state = true } .onChanged { value in guard canStartAction, value.translation.width > value.translation.height else { return } // 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 value.translation.width > abs(value.translation.height) { value.translation.width } 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 } } .onEnded { _ in if xOffset > actionThreshold { action() } xOffset = 0.0 } } /// Used to compute the horizontal translation amount. /// The more it's dragged the less it moves on a circular ease out curve private func circularEaseOut(_ value: Double) -> Double { sqrt((2 - value) * value) } } extension View { func swipeRightAction(label: @escaping () -> some View, shouldStartAction: @escaping () -> Bool, action: @escaping () -> Void) -> some View { modifier(SwipeRightAction(label: label, shouldStartAction: shouldStartAction, action: action)) } } struct SwipeRightAction_Previews: PreviewProvider, TestablePreview { static var previews: some View { Preview() } struct Preview: View { @State private var isPresentingSheet = false var body: some View { ElementNavigationStack { ScrollView { VStack(alignment: .leading, spacing: 2) { Text("This is a message from somebody with a couple of lines of text.") .bubbleBackground() .swipeRightAction { Image(systemName: "flame") } shouldStartAction: { true } action: { isPresentingSheet = true } } .padding() } .navigationTitle("Work chat") .navigationBarTitleDisplayMode(.inline) } .sheet(isPresented: $isPresentingSheet) { Text("Action triggered!") .presentationDetents([.medium]) } } } } /// 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 PanGestureRepresentable: 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) } } }