Skip to content

Commit 19d03a4

Browse files
authored
Add DragPointerObserver (#670)
1 parent 0ec329d commit 19d03a4

File tree

4 files changed

+144
-0
lines changed

4 files changed

+144
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Take a look
88

99
#### Navigator
1010

11+
* Added `DragPointerObserver` to recognize drag gestures with pointer events.
1112
* Added `DirectionalNavigationAdapter.onNavigation` callback to be notified when a navigation action is triggered.
1213
* This callback is called before executing any navigation action.
1314
* Useful for hiding UI elements when the user navigates, or implementing analytics.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//
2+
// Copyright 2025 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
9+
public extension InputObserving where Self == DragPointerObserver {
10+
static func drag(
11+
onStart: @MainActor @escaping (PointerEvent) -> Bool = { _ in false },
12+
onMove: @MainActor @escaping (PointerEvent) -> Bool = { _ in false },
13+
onEnd: @MainActor @escaping (PointerEvent) -> Bool = { _ in false },
14+
onCancel: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }
15+
) -> DragPointerObserver {
16+
DragPointerObserver(
17+
onStart: onStart,
18+
onMove: onMove,
19+
onEnd: onEnd,
20+
onCancel: onCancel
21+
)
22+
}
23+
}
24+
25+
/// Pointer observer recognizing drag gestures.
26+
@MainActor public final class DragPointerObserver: InputObserving {
27+
private let onStart: @MainActor (PointerEvent) -> Bool
28+
private let onMove: @MainActor (PointerEvent) -> Bool
29+
private let onEnd: @MainActor (PointerEvent) -> Bool
30+
private let onCancel: @MainActor (PointerEvent) -> Bool
31+
32+
public init(
33+
onStart: @MainActor @escaping (PointerEvent) -> Bool,
34+
onMove: @MainActor @escaping (PointerEvent) -> Bool,
35+
onEnd: @MainActor @escaping (PointerEvent) -> Bool,
36+
onCancel: @MainActor @escaping (PointerEvent) -> Bool
37+
) {
38+
self.onStart = onStart
39+
self.onMove = onMove
40+
self.onEnd = onEnd
41+
self.onCancel = onCancel
42+
}
43+
44+
private var state: State = .idle
45+
46+
private enum State {
47+
case idle
48+
case pending(id: AnyHashable, startLocation: CGPoint)
49+
case dragging(id: AnyHashable, lastEvent: PointerEvent)
50+
case failed(activePointers: Set<AnyHashable>)
51+
}
52+
53+
private enum Action {
54+
case start(PointerEvent)
55+
case move(PointerEvent)
56+
case end(PointerEvent)
57+
case cancel(PointerEvent)
58+
case none
59+
}
60+
61+
public func didReceive(_ event: KeyEvent) async -> Bool {
62+
false
63+
}
64+
65+
public func didReceive(_ event: PointerEvent) async -> Bool {
66+
let (newState, action) = transition(state: state, event: event)
67+
state = newState
68+
69+
switch action {
70+
case let .start(event):
71+
return onStart(event)
72+
case let .move(event):
73+
return onMove(event)
74+
case let .end(event):
75+
return onEnd(event)
76+
case let .cancel(event):
77+
return onCancel(event)
78+
case .none:
79+
return false
80+
}
81+
}
82+
83+
private func transition(state: State, event: PointerEvent) -> (State, Action) {
84+
let id = event.pointer.id
85+
86+
switch (state, event.phase) {
87+
case (.idle, .down):
88+
return (.pending(id: id, startLocation: event.location), .none)
89+
90+
case let (.pending(pendingID, _), .down) where pendingID != id:
91+
return (.failed(activePointers: [pendingID, id]), .none)
92+
93+
case let (.pending(pendingID, _), .cancel) where pendingID == id:
94+
return (.idle, .none)
95+
96+
case let (.pending(pendingID, startLocation), .move) where pendingID == id:
97+
// Check if pointer has moved enough to start dragging.
98+
if abs(startLocation.x - event.location.x) > 1 || abs(startLocation.y - event.location.y) > 1 {
99+
return (.dragging(id: pendingID, lastEvent: event), .start(event))
100+
} else {
101+
return (.pending(id: pendingID, startLocation: startLocation), .none)
102+
}
103+
104+
case let (.pending(pendingID, _), .up) where pendingID == id:
105+
// Pointer went up without moving - this is a tap, not a drag.
106+
return (.idle, .none)
107+
108+
case let (.dragging(draggingID, lastEvent), .down) where draggingID != id:
109+
// Second pointer detected during drag - cancel the drag
110+
return (.failed(activePointers: [draggingID, id]), .cancel(lastEvent))
111+
112+
case let (.dragging(draggingID, lastEvent), .cancel) where draggingID == id:
113+
return (.idle, .cancel(lastEvent))
114+
115+
case let (.dragging(draggingID, _), .move) where draggingID == id:
116+
return (.dragging(id: draggingID, lastEvent: event), .move(event))
117+
118+
case let (.dragging(draggingID, _), .up) where draggingID == id:
119+
return (.idle, .end(event))
120+
121+
case var (.failed(activePointers), .down):
122+
activePointers.insert(id)
123+
return (.failed(activePointers: activePointers), .none)
124+
125+
case var (.failed(activePointers), .up),
126+
var (.failed(activePointers), .cancel):
127+
activePointers.remove(id)
128+
if activePointers.isEmpty {
129+
return (.idle, .none)
130+
} else {
131+
return (.failed(activePointers: activePointers), .none)
132+
}
133+
134+
default:
135+
return (state, .none)
136+
}
137+
}
138+
}

Support/Carthage/.xcodegen

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@
532532
../../Sources/Navigator/Input/Key/KeyObserver.swift
533533
../../Sources/Navigator/Input/Pointer
534534
../../Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift
535+
../../Sources/Navigator/Input/Pointer/DragPointerObserver.swift
535536
../../Sources/Navigator/Input/Pointer/PointerEvent.swift
536537
../../Sources/Navigator/Navigator.swift
537538
../../Sources/Navigator/PDF

Support/Carthage/Readium.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
0B9AC6EF44DA518E9F37FB49 /* ContentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E809378D79D09192A0AAE1 /* ContentService.swift */; };
3030
0BFCDAEC82CFF09AFC53A5D0 /* LCPDFTableOfContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94414130EC3731CD9920F27D /* LCPDFTableOfContentsService.swift */; };
3131
0C038E3525BB600EF6815EB9 /* ReadiumFuzi.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */; };
32+
0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */; };
3233
0ECE94F27E005FC454EA9D12 /* DecorableNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626CFFF131E0E840B76428F1 /* DecorableNavigator.swift */; };
3334
0F1AAB56A6ADEDDE2AD7E41E /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1039900AC78465AD989D7464 /* Content.swift */; };
3435
1004CE1C72C85CC3702C09C0 /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC811653B33761089E270C4A /* Asset.swift */; };
@@ -540,6 +541,7 @@
540541
21944E1DABB61C2CF2EA89C5 /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = "<group>"; };
541542
230985A228FA74F24735D6BB /* LCPRenewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPRenewDelegate.swift; sourceTree = "<group>"; };
542543
239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBZNavigatorViewController.swift; sourceTree = "<group>"; };
544+
24FF3141286A0CF40643D32D /* DragPointerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPointerObserver.swift; sourceTree = "<group>"; };
543545
251275D0DF87F85158A5FEA9 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Navigator/EPUB/Assets; sourceTree = SOURCE_ROOT; };
544546
258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = "<group>"; };
545547
2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = "<group>"; };
@@ -2050,6 +2052,7 @@
20502052
isa = PBXGroup;
20512053
children = (
20522054
8F485F9F15CF41925D2D3D5C /* ActivatePointerObserver.swift */,
2055+
24FF3141286A0CF40643D32D /* DragPointerObserver.swift */,
20532056
F76073E8E6DACE7F9D22E0DD /* PointerEvent.swift */,
20542057
);
20552058
path = Pointer;
@@ -2391,6 +2394,7 @@
23912394
6BE745329D68EE0533E42D14 /* DiffableDecoration+HTML.swift in Sources */,
23922395
A2B9CE5A5A7F999B4D849C1F /* DiffableDecoration.swift in Sources */,
23932396
8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */,
2397+
0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */,
23942398
2E518C960D386F13E0A5E9B7 /* EPUBFixedSpreadView.swift in Sources */,
23952399
B912ABB7DE8FC1A7A8EC1D84 /* EPUBNavigatorViewController.swift in Sources */,
23962400
9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */,

0 commit comments

Comments
 (0)