Skip to content
This repository was archived by the owner on Oct 10, 2024. It is now read-only.

Commit e069cfb

Browse files
authored
Automatic error handling (#45)
* Bump demo app to iOS 14 * Convert error view to SwiftUI * Move ErrorPresenter in and make delegate optional * reload -> retry * Make ErrorPresenter public * Document error handling * [skip ci] Update CHANGELOG
1 parent 847bc87 commit e069cfb

8 files changed

Lines changed: 125 additions & 133 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Pass a configuration to the web view create block by @seanpdoyle [#41](https://github.com/joemasilotti/TurboNavigator/pull/41)
77
* Option to override `sessionDidLoadWebView(_:)` by @yanshiyason [#35](https://github.com/joemasilotti/TurboNavigator/pull/35)
88
* Handle `/resume_historical_location` route [#38](https://github.com/joemasilotti/TurboNavigator/pull/38)
9+
* Automatically handle errors with option to override [#45](https://github.com/joemasilotti/TurboNavigator/pull/45)
910

1011
### Breaking changes
1112

Demo/Demo.xcodeproj/project.pbxproj

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
/* Begin PBXBuildFile section */
1010
8462832529B8C6B5004EFC9A /* TurboNavigator in Frameworks */ = {isa = PBXBuildFile; productRef = 8462832429B8C6B5004EFC9A /* TurboNavigator */; };
11-
8462832729B90BB9004EFC9A /* ErrorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8462832629B90BB9004EFC9A /* ErrorPresenter.swift */; };
1211
8484C2E929B132D20018596C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8484C2E829B132D20018596C /* AppDelegate.swift */; };
1312
8484C2EB29B132D20018596C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8484C2EA29B132D20018596C /* SceneDelegate.swift */; };
1413
8484C2F229B132D30018596C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8484C2F129B132D30018596C /* Assets.xcassets */; };
@@ -17,7 +16,6 @@
1716

1817
/* Begin PBXFileReference section */
1918
8462832329B8C6A7004EFC9A /* TurboNavigator */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TurboNavigator; path = ..; sourceTree = "<group>"; };
20-
8462832629B90BB9004EFC9A /* ErrorPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorPresenter.swift; sourceTree = "<group>"; };
2119
8484C2E529B132D20018596C /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; };
2220
8484C2E829B132D20018596C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
2321
8484C2EA29B132D20018596C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
@@ -61,7 +59,6 @@
6159
children = (
6260
8484C2F629B132D30018596C /* Info.plist */,
6361
8484C2E829B132D20018596C /* AppDelegate.swift */,
64-
8462832629B90BB9004EFC9A /* ErrorPresenter.swift */,
6562
8484C2EA29B132D20018596C /* SceneDelegate.swift */,
6663
8484C2F129B132D30018596C /* Assets.xcassets */,
6764
8484C2F329B132D30018596C /* LaunchScreen.storyboard */,
@@ -161,7 +158,6 @@
161158
files = (
162159
8484C2E929B132D20018596C /* AppDelegate.swift in Sources */,
163160
8484C2EB29B132D20018596C /* SceneDelegate.swift in Sources */,
164-
8462832729B90BB9004EFC9A /* ErrorPresenter.swift in Sources */,
165161
);
166162
runOnlyForDeploymentPostprocessing = 0;
167163
};
@@ -306,6 +302,7 @@
306302
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
307303
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
308304
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
305+
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
309306
LD_RUNPATH_SEARCH_PATHS = (
310307
"$(inherited)",
311308
"@executable_path/Frameworks",
@@ -335,6 +332,7 @@
335332
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
336333
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
337334
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
335+
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
338336
LD_RUNPATH_SEARCH_PATHS = (
339337
"$(inherited)",
340338
"@executable_path/Frameworks",

Demo/Demo/ErrorPresenter.swift

Lines changed: 0 additions & 115 deletions
This file was deleted.

Demo/Demo/SceneDelegate.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
1818
private let baseURL = URL(string: "http://localhost:3000")!
1919
private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration)
2020
private lazy var pathConfiguration = PathConfiguration(sources: [
21-
.server(baseURL.appending(path: "/configuration"))
21+
.server(baseURL.appendingPathComponent("/configuration"))
2222
])
2323

2424
private func createWindow(in windowScene: UIWindowScene) {
@@ -33,12 +33,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3333
}
3434
}
3535

36-
extension SceneDelegate: TurboNavigationDelegate {
37-
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
38-
if let errorPresenter = visitable as? ErrorPresenter {
39-
errorPresenter.presentError(error) {
40-
session.reload()
41-
}
42-
}
43-
}
44-
}
36+
extension SceneDelegate: TurboNavigationDelegate {}

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,23 @@ class MyCustomClass: TurboNavigationDelegate {
255255
}
256256
}
257257
```
258+
259+
### Customized error handling
260+
261+
By default, Turbo Navigator will automatically handle any errors that occur when performing visits. The error's localized description and a button to retry the request is displayed.
262+
263+
You can customize the error handling by overriding the following delegate method.
264+
265+
```swift
266+
extension MyCustomClass: TurboNavigationDelegate {
267+
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
268+
if case let TurboError.http(statusCode) = error, statusCode == 401 {
269+
// Custom error handling for 401 responses.
270+
} else if let errorPresenter = visitable as? ErrorPresenter {
271+
errorPresenter.presentError(error) {
272+
retry()
273+
}
274+
}
275+
}
276+
}
277+
```

Sources/ErrorPresenter.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
public protocol ErrorPresenter: UIViewController {
5+
typealias Handler = () -> Void
6+
7+
func presentError(_ error: Error, handler: @escaping Handler)
8+
}
9+
10+
public extension ErrorPresenter {
11+
func presentError(_ error: Error, handler: @escaping () -> Void) {
12+
let errorView = ErrorView(error: error) { [unowned self] in
13+
handler()
14+
self.removeErrorViewController()
15+
}
16+
17+
let controller = UIHostingController(rootView: errorView)
18+
addChild(controller)
19+
addFullScreenSubview(controller.view)
20+
controller.didMove(toParent: self)
21+
}
22+
23+
private func removeErrorViewController() {
24+
if let child = children.first(where: { $0 is UIHostingController<ErrorView> }) {
25+
child.willMove(toParent: nil)
26+
child.view.removeFromSuperview()
27+
child.removeFromParent()
28+
}
29+
}
30+
}
31+
32+
extension UIViewController: ErrorPresenter {}
33+
34+
// MARK: Private
35+
36+
private struct ErrorView: View {
37+
let error: Error
38+
let handler: ErrorPresenter.Handler?
39+
40+
var body: some View {
41+
VStack(spacing: 16) {
42+
Image(systemName: "exclamationmark.triangle")
43+
.font(.system(size: 38, weight: .semibold))
44+
.foregroundColor(.accentColor)
45+
46+
Text("Error loading page")
47+
.font(.largeTitle)
48+
49+
Text(error.localizedDescription)
50+
.font(.body)
51+
.multilineTextAlignment(.center)
52+
53+
Button("Retry") {
54+
handler?()
55+
}
56+
.font(.system(size: 17, weight: .bold))
57+
}
58+
.padding(32)
59+
}
60+
}
61+
62+
private struct ErrorView_Previews: PreviewProvider {
63+
static var previews: some View {
64+
return ErrorView(error: NSError(
65+
domain: "com.example.error",
66+
code: 1001,
67+
userInfo: [NSLocalizedDescriptionKey: "Could not connect to the server."]
68+
)) {}
69+
}
70+
}
71+
72+
private extension UIViewController {
73+
func addFullScreenSubview(_ subview: UIView) {
74+
view.addSubview(subview)
75+
subview.translatesAutoresizingMaskIntoConstraints = false
76+
NSLayoutConstraint.activate([
77+
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
78+
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
79+
subview.topAnchor.constraint(equalTo: view.topAnchor),
80+
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor)
81+
])
82+
}
83+
}

Sources/TurboNavigationDelegate.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import WebKit
55
/// Implement to be notified when certain navigations are performed
66
/// or to render a native controller instead of a Turbo web visit.
77
public protocol TurboNavigationDelegate: AnyObject {
8-
/// An error occurred loading the request, present it to the user.
9-
/// Retry the request by calling `session.reload()`.
10-
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error)
8+
typealias RetryBlock = () -> Void
119

1210
/// Respond to authentication challenge presented by web servers behing basic auth.
1311
func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
@@ -16,6 +14,11 @@ public protocol TurboNavigationDelegate: AnyObject {
1614
/// Return `nil` to not display or route anything.
1715
func controller(_ controller: VisitableViewController, forProposal proposal: VisitProposal) -> UIViewController?
1816

17+
/// Optional. An error occurred loading the request, present it to the user.
18+
/// Retry the request by executing the closure.
19+
/// If not implemented, will present the error's localized description and a Retry button.
20+
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock)
21+
1922
/// Optional. Implement to customize handling of external URLs.
2023
/// If not implemented, will present `SFSafariViewController` as a modal and load the URL.
2124
func openExternalURL(_ url: URL, from controller: UIViewController)
@@ -30,6 +33,14 @@ public extension TurboNavigationDelegate {
3033
VisitableViewController(url: proposal.url)
3134
}
3235

36+
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
37+
if let errorPresenter = visitable as? ErrorPresenter {
38+
errorPresenter.presentError(error) {
39+
retry()
40+
}
41+
}
42+
}
43+
3344
func openExternalURL(_ url: URL, from controller: UIViewController) {
3445
let safariViewController = SFSafariViewController(url: url)
3546
safariViewController.modalPresentationStyle = .pageSheet

Sources/TurboNavigator.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ extension TurboNavigator: SessionDelegate {
215215
}
216216

217217
public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
218-
delegate.session(session, didFailRequestForVisitable: visitable, error: error)
218+
delegate.visitableDidFailRequest(visitable, error: error) {
219+
session.reload()
220+
}
219221
}
220222

221223
public func sessionWebViewProcessDidTerminate(_ session: Session) {

0 commit comments

Comments
 (0)