// // Swiftfin is subject to the terms of the Mozilla Public // License, v2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at https://mozilla.org/MPL/2.0/. // // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // import Combine import Foundation import SwiftUI // TODO: change swipe to directional // TODO: figure out way for multitap near the middle be distinguished as different sides // state, point, velocity, translation typealias PanGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat, CGFloat) -> Void // state, point, scale typealias PinchGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat) -> Void // point, direction, amount typealias SwipeGestureHandler = (UnitPoint, Bool, Int) -> Void // point, amount typealias TapGestureHandler = (UnitPoint, Int) -> Void struct GestureView: UIViewRepresentable { private var onHorizontalPan: PanGestureHandler? private var onHorizontalSwipe: SwipeGestureHandler? private var onLongPress: ((UnitPoint) -> Void)? private var onPinch: PinchGestureHandler? private var onTap: TapGestureHandler? private var onDoubleTouch: TapGestureHandler? private var onVerticalPan: PanGestureHandler? private var longPressMinimumDuration: TimeInterval private var samePointPadding: CGFloat private var samePointTimeout: TimeInterval private var swipeTranslation: CGFloat private var swipeVelocity: CGFloat private var sameSwipeDirectionTimeout: TimeInterval func makeUIView(context: Context) -> UIGestureView { UIGestureView( onHorizontalPan: onHorizontalPan, onHorizontalSwipe: onHorizontalSwipe, onLongPress: onLongPress, onPinch: onPinch, onTap: onTap, onDoubleTouch: onDoubleTouch, onVerticalPan: onVerticalPan, longPressMinimumDuration: longPressMinimumDuration, samePointPadding: samePointPadding, samePointTimeout: samePointTimeout, swipeTranslation: swipeTranslation, swipeVelocity: swipeVelocity, sameSwipeDirectionTimeout: sameSwipeDirectionTimeout ) } func updateUIView(_ uiView: UIGestureView, context: Context) {} } extension GestureView { init() { self.init( longPressMinimumDuration: 0, samePointPadding: 0, samePointTimeout: 0, swipeTranslation: 0, swipeVelocity: 0, sameSwipeDirectionTimeout: 0 ) } func onHorizontalPan(_ action: @escaping PanGestureHandler) -> Self { copy(modifying: \.onHorizontalPan, with: action) } func onHorizontalSwipe( translation: CGFloat, velocity: CGFloat, sameSwipeDirectionTimeout: TimeInterval = 0, _ action: @escaping SwipeGestureHandler ) -> Self { copy(modifying: \.swipeTranslation, with: translation) .copy(modifying: \.swipeVelocity, with: velocity) .copy(modifying: \.sameSwipeDirectionTimeout, with: sameSwipeDirectionTimeout) .copy(modifying: \.onHorizontalSwipe, with: action) } func onPinch(_ action: @escaping PinchGestureHandler) -> Self { copy(modifying: \.onPinch, with: action) } func onTap( samePointPadding: CGFloat, samePointTimeout: TimeInterval, _ action: @escaping TapGestureHandler ) -> Self { copy(modifying: \.samePointPadding, with: samePointPadding) .copy(modifying: \.samePointTimeout, with: samePointTimeout) .copy(modifying: \.onTap, with: action) } func onDoubleTouch(_ action: @escaping TapGestureHandler) -> Self { copy(modifying: \.onDoubleTouch, with: action) } func onLongPress(minimumDuration: TimeInterval, _ action: @escaping (UnitPoint) -> Void) -> Self { copy(modifying: \.longPressMinimumDuration, with: minimumDuration) .copy(modifying: \.onLongPress, with: action) } func onVerticalPan(_ action: @escaping PanGestureHandler) -> Self { copy(modifying: \.onVerticalPan, with: action) } } class UIGestureView: UIView { private let onHorizontalPan: PanGestureHandler? private let onHorizontalSwipe: SwipeGestureHandler? private let onLongPress: ((UnitPoint) -> Void)? private let onPinch: PinchGestureHandler? private let onTap: TapGestureHandler? private let onDoubleTouch: TapGestureHandler? private let onVerticalPan: PanGestureHandler? private let longPressMinimumDuration: TimeInterval private let samePointPadding: CGFloat private let samePointTimeout: TimeInterval private let swipeTranslation: CGFloat private let swipeVelocity: CGFloat private var sameSwipeDirectionTimeout: TimeInterval private var hasSwiped: Bool = false private var lastSwipeDirection: Bool? private var lastTouchLocation: CGPoint? private var multiTapWorkItem: DispatchWorkItem? private var sameSwipeWorkItem: DispatchWorkItem? private var multiTapAmount: Int = 0 private var sameSwipeAmount: Int = 0 init( onHorizontalPan: PanGestureHandler?, onHorizontalSwipe: SwipeGestureHandler?, onLongPress: ((UnitPoint) -> Void)?, onPinch: PinchGestureHandler?, onTap: TapGestureHandler?, onDoubleTouch: TapGestureHandler?, onVerticalPan: PanGestureHandler?, longPressMinimumDuration: TimeInterval, samePointPadding: CGFloat, samePointTimeout: TimeInterval, swipeTranslation: CGFloat, swipeVelocity: CGFloat, sameSwipeDirectionTimeout: TimeInterval ) { self.onHorizontalPan = onHorizontalPan self.onHorizontalSwipe = onHorizontalSwipe self.onLongPress = onLongPress self.onPinch = onPinch self.onTap = onTap self.onDoubleTouch = onDoubleTouch self.onVerticalPan = onVerticalPan self.longPressMinimumDuration = longPressMinimumDuration self.samePointPadding = samePointPadding self.samePointTimeout = samePointTimeout self.swipeTranslation = swipeTranslation self.swipeVelocity = swipeVelocity self.sameSwipeDirectionTimeout = sameSwipeDirectionTimeout super.init(frame: .zero) let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPerformPinch)) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformTap)) let doubleTouchGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformDoubleTouch)) doubleTouchGesture.numberOfTouchesRequired = 2 let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didPerformLongPress)) longPressGesture.minimumPressDuration = longPressMinimumDuration let verticalPanGesture = PanDirectionGestureRecognizer( direction: .vertical, target: self, action: #selector(didPerformVerticalPan) ) let horizontalPanGesture = PanDirectionGestureRecognizer( direction: .horizontal, target: self, action: #selector(didPerformHorizontalPan) ) addGestureRecognizer(pinchGesture) addGestureRecognizer(tapGesture) addGestureRecognizer(doubleTouchGesture) addGestureRecognizer(longPressGesture) addGestureRecognizer(verticalPanGesture) addGestureRecognizer(horizontalPanGesture) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func didPerformHorizontalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { let translation = gestureRecognizer.translation(in: self).x let unitPoint = gestureRecognizer.unitPoint(in: self) let velocity = gestureRecognizer.velocity(in: self).x onHorizontalPan?(gestureRecognizer.state, unitPoint, velocity, translation) if !hasSwiped, abs(translation) >= swipeTranslation, abs(velocity) >= swipeVelocity { didPerformSwipe(unitPoint: unitPoint, direction: translation > 0) hasSwiped = true } if gestureRecognizer.state == .ended { hasSwiped = false } } private func didPerformSwipe(unitPoint: UnitPoint, direction: Bool) { if lastSwipeDirection == direction { sameSwipeOccurred(unitPoint: unitPoint, direction: direction) onHorizontalSwipe?(unitPoint, direction, sameSwipeAmount) } else { sameSwipeOccurred(unitPoint: unitPoint, direction: direction) onHorizontalSwipe?(unitPoint, direction, 1) } } private func sameSwipeOccurred(unitPoint: UnitPoint, direction: Bool) { guard sameSwipeDirectionTimeout > 0 else { return } lastSwipeDirection = direction sameSwipeAmount += 1 sameSwipeWorkItem?.cancel() let task = DispatchWorkItem { self.sameSwipeAmount = 0 self.lastSwipeDirection = nil } sameSwipeWorkItem = task DispatchQueue.main.asyncAfter(deadline: .now() + sameSwipeDirectionTimeout, execute: task) } @objc private func didPerformLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { guard let onLongPress, gestureRecognizer.state == .began else { return } let unitPoint = gestureRecognizer.unitPoint(in: self) onLongPress(unitPoint) } @objc private func didPerformPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { guard let onPinch else { return } let unitPoint = gestureRecognizer.unitPoint(in: self) onPinch(gestureRecognizer.state, unitPoint, gestureRecognizer.scale) } @objc private func didPerformTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let onTap else { return } let location = gestureRecognizer.location(in: self) let unitPoint = gestureRecognizer.unitPoint(in: self) if let lastTouchLocation, lastTouchLocation.isNear(lastTouchLocation, padding: samePointPadding) { multiTapOccurred(at: location) onTap(unitPoint, multiTapAmount) } else { multiTapOccurred(at: location) onTap(unitPoint, 1) } } @objc private func didPerformDoubleTouch(_ gestureRecognizer: UITapGestureRecognizer) { guard let onDoubleTouch else { return } let unitPoint = gestureRecognizer.unitPoint(in: self) onDoubleTouch(unitPoint, 1) } @objc private func didPerformVerticalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { guard let onVerticalPan else { return } let translation = gestureRecognizer.translation(in: self).y let unitPoint = gestureRecognizer.unitPoint(in: self) let velocity = gestureRecognizer.velocity(in: self).y onVerticalPan(gestureRecognizer.state, unitPoint, velocity, translation) } private func multiTapOccurred(at location: CGPoint) { guard samePointTimeout > 0 else { return } lastTouchLocation = location multiTapAmount += 1 multiTapWorkItem?.cancel() let task = DispatchWorkItem { self.multiTapAmount = 0 self.lastTouchLocation = nil } multiTapWorkItem = task DispatchQueue.main.asyncAfter(deadline: .now() + samePointTimeout, execute: task) } }