import UIKit final class RDEPUBTextContentInteractionCoordinator: NSObject { enum SelectionInteractionState: Equatable { case idle case selectionPending case selecting case selectionActive case adjustingHandle } struct Dependencies { let hasRenderableContent: () -> Bool let currentSelectionProvider: () -> RDEPUBSelection? let isSelectionControllerSelecting: () -> Bool let hasActiveSelection: () -> Bool let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle? let selectionContainsPoint: (CGPoint) -> Bool let renderPointForGesture: (UIGestureRecognizer) -> CGPoint let renderPointForTouch: (UITouch) -> CGPoint let performLongPressSelection: (UILongPressGestureRecognizer) -> Void let performPanSelection: (UIPanGestureRecognizer) -> Void let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void let presentLoupeAtPoint: (CGPoint) -> Void let dismissLoupe: () -> Void let showSelectionMenu: () -> Void let hideSelectionMenu: () -> Void let selectionTapSuppressionDidChange: (Bool) -> Void let selectionPagingSuppressionDidChange: (Bool) -> Void } private let dependencies: Dependencies private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle? private(set) var interactionState: SelectionInteractionState = .idle var isInteractionInProgress: Bool { interactionState != .idle } init(dependencies: Dependencies) { self.dependencies = dependencies super.init() } func handleLongPress(_ gesture: UILongPressGestureRecognizer) { dependencies.performLongPressSelection(gesture) switch gesture.state { case .began: activeSelectionHandle = nil updateSelectionInteractionState(.selecting) dependencies.hideSelectionMenu() dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture)) case .changed: updateSelectionInteractionState(.selecting) dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture)) case .ended: activeSelectionHandle = nil dependencies.dismissLoupe() dependencies.showSelectionMenu() case .cancelled, .failed: activeSelectionHandle = nil dependencies.dismissLoupe() default: break } } func handlePan(_ gesture: UIPanGestureRecognizer) { let point = dependencies.renderPointForGesture(gesture) if gesture.state == .began, activeSelectionHandle == nil, let handle = dependencies.selectionHandleAtPoint(point) { activeSelectionHandle = handle updateSelectionInteractionState(.adjustingHandle) dependencies.hideSelectionMenu() dependencies.presentLoupeAtPoint(point) } if let activeSelectionHandle { dependencies.adjustSelection(activeSelectionHandle, point) dependencies.presentLoupeAtPoint(point) } else { dependencies.performPanSelection(gesture) if dependencies.isSelectionControllerSelecting() { dependencies.presentLoupeAtPoint(point) } } switch gesture.state { case .ended: activeSelectionHandle = nil updateSelectionInteractionState( dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive ) dependencies.dismissLoupe() dependencies.showSelectionMenu() case .cancelled, .failed: activeSelectionHandle = nil updateSelectionInteractionState( dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive ) dependencies.dismissLoupe() default: break } } func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) { switch state { case .idle: if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil { updateSelectionInteractionState(.idle) } case .selecting: updateSelectionInteractionState(.selecting) case .selectionActive: updateSelectionInteractionState( dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive ) case .adjustingHandle: updateSelectionInteractionState(.adjustingHandle) } } func touchesBegan(_ touches: Set, with event: UIEvent?) { guard interactionState == .idle, activeSelectionHandle == nil, dependencies.hasRenderableContent(), let touch = touches.first else { return } let point = dependencies.renderPointForTouch(touch) guard dependencies.selectionHandleAtPoint(point) == nil else { return } updateSelectionInteractionState(.selectionPending) } func touchesEnded(_ touches: Set, with event: UIEvent?) { resetSelectionPendingIfNeeded() } func touchesCancelled(_ touches: Set, with event: UIEvent?) { resetSelectionPendingIfNeeded() } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer { if dependencies.isSelectionControllerSelecting() { return true } let point = dependencies.renderPointForGesture(gestureRecognizer) return dependencies.selectionHandleAtPoint(point) != nil } if gestureRecognizer is UILongPressGestureRecognizer { guard dependencies.hasRenderableContent() else { return true } let point = dependencies.renderPointForGesture(gestureRecognizer) if dependencies.selectionHandleAtPoint(point) != nil { return false } if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) { return false } return true } return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard dependencies.hasRenderableContent() else { return true } let point = dependencies.renderPointForTouch(touch) if let handle = dependencies.selectionHandleAtPoint(point) { if gestureRecognizer is UIPanGestureRecognizer { activeSelectionHandle = handle updateSelectionInteractionState(.adjustingHandle) dependencies.hideSelectionMenu() return true } if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer { return false } } return true } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer } func reset() { activeSelectionHandle = nil updateSelectionInteractionState(.idle) dependencies.dismissLoupe() } private func resetSelectionPendingIfNeeded() { guard interactionState == .selectionPending else { return } if dependencies.currentSelectionProvider() != nil { updateSelectionInteractionState(.selectionActive) } else { updateSelectionInteractionState(.idle) } } private func updateSelectionInteractionState(_ state: SelectionInteractionState) { let previousTapSuppressed = interactionState != .idle let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) let previousState = interactionState interactionState = state let currentTapSuppressed = interactionState != .idle let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) if previousState != state { RDReaderTapDebug.log( "TextContentInteraction.state", "transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)" ) } if previousTapSuppressed != currentTapSuppressed { dependencies.selectionTapSuppressionDidChange(currentTapSuppressed) } if previousPagingSuppressed != currentPagingSuppressed { dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed) } } private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool { switch state { case .idle, .selectionPending, .selectionActive: return false case .selecting, .adjustingHandle: return true } } }