Skip to content

New API to observe user input #590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ env:
platform: ${{ 'iOS Simulator' }}
device: ${{ 'iPhone SE (3rd generation)' }}
commit_sha: ${{ github.sha }}
DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer
DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer

jobs:
build:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ All notable changes to this project will be documented in this file. Take a look

* Implementation of the [W3C Accessibility Metadata Display Guide](https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/) specification to facilitate displaying accessibility metadata to users. [See the dedicated user guide](docs/Guides/Accessibility.md).

#### Navigator

* A new `InputObserving` API has been added to enable more flexible gesture recognition and support for mouse pointers. [See the dedicated user guide](docs/Guides/Navigator/Input.md).

### Fixed

#### Navigator
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Guides are available to help you make the most of the toolkit.

| Readium | iOS | Swift compiler | Xcode |
|-----------|------|----------------|-------|
| `develop` | 13.4 | 5.10 | 15.4 |
| `develop` | 13.4 | 6.0 | 16.2 |
| 3.0.0 | 13.4 | 5.10 | 15.4 |
| 2.5.1 | 11.0 | 5.6.1 | 13.4 |
| 2.5.0 | 10.0 | 5.6.1 | 13.4 |
Expand Down
27 changes: 20 additions & 7 deletions Sources/Navigator/CBZ/CBZNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import UIKit
public protocol CBZNavigatorDelegate: VisualNavigatorDelegate {}

/// A view controller used to render a CBZ `Publication`.
open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggable {
open class CBZNavigatorViewController:
InputObservableViewController,
VisualNavigator, Loggable
{
enum Error: Swift.Error {
/// The provided publication is restricted. Check that any DRM was
/// properly unlocked using a Content Protection.
Expand Down Expand Up @@ -103,6 +106,21 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab
)

super.init(nibName: nil, bundle: nil)

setupLegacyInputCallbacks(
onTap: { [weak self] point in
guard let self else { return }
self.delegate?.navigator(self, didTapAt: point)
},
onPressKey: { [weak self] event in
guard let self else { return }
self.delegate?.navigator(self, didPressKey: event)
},
onReleaseKey: { [weak self] event in
guard let self else { return }
self.delegate?.navigator(self, didReleaseKey: event)
}
)
}

private func didLoadPositions(_ positions: [Locator]?) {
Expand Down Expand Up @@ -132,7 +150,7 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab
view.addSubview(pageViewController.view)
pageViewController.didMove(toParent: self)

view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap)))
view.addGestureRecognizer(InputObservingGestureRecognizerAdapter(observer: inputObservers))

tasks.add {
try? await didLoadPositions(publication.positions().get())
Expand Down Expand Up @@ -187,11 +205,6 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab
return true
}

@objc private func didTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: view)
delegate?.navigator(self, didTapAt: point)
}

private func imageViewController(at index: Int) -> ImageViewController? {
guard publication.readingOrder.indices.contains(index) else {
return nil
Expand Down
90 changes: 64 additions & 26 deletions Sources/Navigator/DirectionalNavigationAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ public final class DirectionalNavigationAdapter {
}
}

private weak var navigator: VisualNavigator?
private let tapEdges: TapEdges
private let handleTapsWhileScrolling: Bool
private let minimumHorizontalEdgeSize: Double
Expand All @@ -39,10 +38,11 @@ public final class DirectionalNavigationAdapter {
private let verticalEdgeThresholdPercent: Double?
private let animatedTransition: Bool

private weak var navigator: VisualNavigator?

/// Initializes a new `DirectionalNavigationAdapter`.
///
/// - Parameters:
/// - navigator: Navigator used to turn pages.
/// - tapEdges: Indicates which viewport edges handle taps.
/// - handleTapsWhileScrolling: Indicates whether the page turns should be
/// handled when the publication is scrollable.
Expand All @@ -59,7 +59,6 @@ public final class DirectionalNavigationAdapter {
/// - animatedTransition: Indicates whether the page turns should be
/// animated.
public init(
navigator: VisualNavigator,
tapEdges: TapEdges = .horizontal,
handleTapsWhileScrolling: Bool = false,
minimumHorizontalEdgeSize: Double = 80.0,
Expand All @@ -68,7 +67,6 @@ public final class DirectionalNavigationAdapter {
verticalEdgeThresholdPercent: Double? = 0.3,
animatedTransition: Bool = false
) {
self.navigator = navigator
self.tapEdges = tapEdges
self.handleTapsWhileScrolling = handleTapsWhileScrolling
self.minimumHorizontalEdgeSize = minimumHorizontalEdgeSize
Expand All @@ -78,19 +76,28 @@ public final class DirectionalNavigationAdapter {
self.animatedTransition = animatedTransition
}

/// Turn pages when `point` is located in one of the tap edges.
///
/// To be called from `VisualNavigatorDelegate.navigator(_:didTapAt:)`.
/// Binds the adapter to the given visual navigator.
///
/// - Parameter point: Tap point in the navigator bounds.
/// - Returns: Whether the tap triggered a page turn.
/// It will automatically observe pointer and key events to turn pages.
@MainActor public func bind(to navigator: VisualNavigator) {
navigator.addObserver(.tap { [self, weak navigator] event in
guard let navigator = navigator else {
return false
}
return await onTap(at: event.location, in: navigator)
})

navigator.addObserver(.key { [self, weak navigator] event in
guard let navigator = navigator else {
return false
}
return await onKey(event, in: navigator)
})
}

@MainActor
@discardableResult
public func didTap(at point: CGPoint) async -> Bool {
guard
let navigator = navigator,
handleTapsWhileScrolling || !navigator.presentation.scroll
else {
private func onTap(at point: CGPoint, in navigator: VisualNavigator) async -> Bool {
guard handleTapsWhileScrolling || !navigator.presentation.scroll else {
return false
}

Expand Down Expand Up @@ -128,17 +135,8 @@ public final class DirectionalNavigationAdapter {
return false
}

/// Turn pages when the arrow or space keys are used.
///
/// To be called from `VisualNavigatorDelegate.navigator(_:didPressKey:)`
///
/// - Returns: Whether the key press triggered a page turn.
@discardableResult
public func didPressKey(event: KeyEvent) async -> Bool {
guard
let navigator = navigator,
event.modifiers.isEmpty
else {
private func onKey(_ event: KeyEvent, in navigator: VisualNavigator) async -> Bool {
guard event.modifiers.isEmpty else {
return false
}

Expand All @@ -157,4 +155,44 @@ public final class DirectionalNavigationAdapter {
return false
}
}

@available(*, deprecated, message: "Use the initializer without the navigator parameter and call `bind(to:)`. See the migration guide.")
public init(
navigator: VisualNavigator,
tapEdges: TapEdges = .horizontal,
handleTapsWhileScrolling: Bool = false,
minimumHorizontalEdgeSize: Double = 80.0,
horizontalEdgeThresholdPercent: Double? = 0.3,
minimumVerticalEdgeSize: Double = 80.0,
verticalEdgeThresholdPercent: Double? = 0.3,
animatedTransition: Bool = false
) {
self.navigator = navigator
self.tapEdges = tapEdges
self.handleTapsWhileScrolling = handleTapsWhileScrolling
self.minimumHorizontalEdgeSize = minimumHorizontalEdgeSize
self.horizontalEdgeThresholdPercent = horizontalEdgeThresholdPercent
self.minimumVerticalEdgeSize = minimumVerticalEdgeSize
self.verticalEdgeThresholdPercent = verticalEdgeThresholdPercent
self.animatedTransition = animatedTransition
}

@available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.")
@MainActor
@discardableResult
public func didTap(at point: CGPoint) async -> Bool {
guard let navigator = navigator else {
return false
}
return await onTap(at: point, in: navigator)
}

@available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.")
@discardableResult
public func didPressKey(event: KeyEvent) async -> Bool {
guard let navigator = navigator else {
return false
}
return await onKey(event, in: navigator)
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading