Files
letro-ios/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift
Mauro 56eec826df Fix A11y tests (#5104)
* replace NavigationStack with ElementNavigationStack to allow the content to be rendered without a NavigationStack in a11y tests

* fix a11y tests

* update xcodeproject

* swiftformat fix

* use iOS 26.1 for CI

* use a wrapper to solve the issue for a11y tests

* ElementNavigationStack only uses the trick in DEBUG mode, and added a swiftlint rule to prevent the usage of NavigationStack
2026-02-13 16:45:58 +01:00

234 lines
9.7 KiB
Swift

//
// 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<Label: View>: 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)
}
}
}