diff --git a/Cartfile.resolved b/Cartfile.resolved index d1d15042..11fe8e89 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ github "Hearst-DD/ObjectMapper" "2.2.0" -github "Readium/r2-shared-swift" "07f5fe26381e0980925f7a16e8c05a1c7455ea45" +github "Readium/r2-shared-swift" "3dd222c34fcf658afd6bc4fd3e06218d773421bb" diff --git a/r2-navigator-swift.xcodeproj/project.pbxproj b/r2-navigator-swift.xcodeproj/project.pbxproj index 6a1a77e9..36b28f7f 100644 --- a/r2-navigator-swift.xcodeproj/project.pbxproj +++ b/r2-navigator-swift.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 1D3A2F56218C85A100108B31 /* PageTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A2F55218C85A100108B31 /* PageTransition.swift */; }; F341C2711F506ED5005E6758 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F341C2701F506ED5005E6758 /* UserSettings.swift */; }; F3E7D3D41F4D83B000DF166D /* r2-navigator-swift.h in Headers */ = {isa = PBXBuildFile; fileRef = F3E7D3C61F4D83B000DF166D /* r2-navigator-swift.h */; settings = {ATTRIBUTES = (Public, ); }; }; F3E7D3DE1F4D845B00DF166D /* BinaryLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E7D3DD1F4D845B00DF166D /* BinaryLocation.swift */; }; @@ -19,6 +20,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1D3A2F55218C85A100108B31 /* PageTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTransition.swift; sourceTree = ""; }; F341C2701F506ED5005E6758 /* UserSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; F3B2C86C1F603E79007601E4 /* SwiftyJSON.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftyJSON.framework; path = Carthage/Build/iOS/SwiftyJSON.framework; sourceTree = ""; }; F3E7D3C31F4D83B000DF166D /* R2Navigator.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = R2Navigator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +105,7 @@ F3E7D3DD1F4D845B00DF166D /* BinaryLocation.swift */, F3E7D3DF1F4D847E00DF166D /* Disjunction.swift */, F341C2701F506ED5005E6758 /* UserSettings.swift */, + 1D3A2F55218C85A100108B31 /* PageTransition.swift */, ); name = EPUB; sourceTree = ""; @@ -203,6 +206,7 @@ F341C2711F506ED5005E6758 /* UserSettings.swift in Sources */, F3E7D3DE1F4D845B00DF166D /* BinaryLocation.swift in Sources */, F3E7D3E41F4D84DC00DF166D /* TriptychView.swift in Sources */, + 1D3A2F56218C85A100108B31 /* PageTransition.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/r2-navigator-swift/NavigatorViewController.swift b/r2-navigator-swift/NavigatorViewController.swift index ff3878c1..39ed2305 100644 --- a/r2-navigator-swift/NavigatorViewController.swift +++ b/r2-navigator-swift/NavigatorViewController.swift @@ -12,6 +12,7 @@ import UIKit import R2Shared import WebKit +import SafariServices public protocol NavigatorDelegate: class { func middleTapHandler() @@ -20,6 +21,8 @@ public protocol NavigatorDelegate: class { /// It changes when html file resource changed func didChangedDocumentPage(currentDocumentIndex: Int) func didChangedPaginatedDocumentPage(currentPage: Int, documentTotalPage: Int) + func didNavigateViaInternalLinkTap(to documentIndex: Int) + func didTapExternalUrl(_ : URL) } public extension NavigatorDelegate { @@ -30,6 +33,19 @@ public extension NavigatorDelegate { func didChangedPaginatedDocumentPage(currentPage: Int, documentTotalPage: Int) { // optional } + func didNavigateViaInternalLinkTap(to documentIndex: Int) { + // optional + } + + func didTapExternalUrl(_ url: URL) { + // optional + // TODO following lines have been moved from the original implementation and might need to be revisited at some point + let view = SFSafariViewController(url: url) + + UIApplication.shared.keyWindow?.rootViewController?.present(view, + animated: true, + completion: nil) + } } open class NavigatorViewController: UIViewController { @@ -41,15 +57,17 @@ open class NavigatorViewController: UIViewController { public let publication: Publication public weak var delegate: NavigatorDelegate? + public let pageTransition: PageTransition + /// - Parameters: /// - publication: The publication. /// - initialIndex: Inital index of -1 will open the publication's at the end. - public init(for publication: Publication, initialIndex: Int, initialProgression: Double?) { + public init(for publication: Publication, initialIndex: Int, initialProgression: Double?, pageTransition: PageTransition = .none) { self.publication = publication self.initialProgression = initialProgression + self.pageTransition = pageTransition userSettings = UserSettings() publication.userProperties.properties = userSettings.userProperties.properties - userSettings.userSettingsUIPreset = publication.userSettingsUIPreset delegatee = Delegatee() var index = initialIndex @@ -72,6 +90,8 @@ open class NavigatorViewController: UIViewController { open override func viewDidLoad() { super.viewDidLoad() delegatee.parent = self + view.backgroundColor = .clear + triptychView.backgroundColor = .clear triptychView.delegate = delegatee triptychView.frame = view.bounds triptychView.autoresizingMask = [.flexibleHeight, .flexibleWidth] @@ -87,6 +107,7 @@ open class NavigatorViewController: UIViewController { } open override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) // Save the currently opened document index and progression. if navigationController == nil { let progression = triptychView.getCurrentDocumentProgression() @@ -106,37 +127,52 @@ extension NavigatorViewController { guard publication.spine.indices.contains(index) else { return } - triptychView.moveTo(index: index) + performTriptychViewTransition { + self.triptychView.moveTo(index: index) + } } /// Display the spine item at `index` with scroll `progression` /// /// - Parameter index: The index of the spine item to display. public func displaySpineItem(at index: Int, progression: Double) { - displaySpineItem(at: index) - if let webView = triptychView.currentView as? WebView { - webView.scrollAt(position: progression) + guard publication.spine.indices.contains(index) else { + return + } + + performTriptychViewTransitionDelayed { + // This is so the webview will move to it's correct progression if it's not loaded into the triptych view + self.initialProgression = progression + self.triptychView.moveTo(index: index) + if let webView = self.triptychView.currentView as? WebView { + // This is needed for when the webView is loaded into the triptychView + webView.scrollAt(position: progression) + } } } /// Load resource with the corresponding href. /// /// - Parameter href: The href of the resource to load. Can contain a tag id. - public func displaySpineItem(with href: String) { + /// - Returns: The spine index for the link + public func displaySpineItem(with href: String) -> Int? { // remove id if any let components = href.components(separatedBy: "#") guard let href = components.first else { - return + return nil } guard let index = publication.spine.index(where: { $0.href?.contains(href) ?? false }) else { - return + return nil } // If any id found, set the scroll position to it, else to the // beggining of the document. let id = (components.count > 1 ? components.last : "") // Jumping set to true to avoid clamping. - triptychView.moveTo(index: index, id: id) + performTriptychViewTransition { + self.triptychView.moveTo(index: index, id: id) + } + return index } public func getSpine() -> [Link] { @@ -160,6 +196,16 @@ extension NavigatorViewController { } extension NavigatorViewController: ViewDelegate { + + func handleTapOnLink(with url: URL) { + delegate?.didTapExternalUrl(url) + } + + func handleTapOnInternalLink(with href: String) { + guard let index = displaySpineItem(with: href) else { return } + delegate?.didNavigateViaInternalLinkTap(to: index) + } + func documentPageDidChanged(webview: WebView, currentPage: Int, totalPage: Int) { if triptychView.currentView == webview { delegate?.didChangedPaginatedDocumentPage(currentPage: currentPage, documentTotalPage: totalPage) @@ -169,13 +215,13 @@ extension NavigatorViewController: ViewDelegate { /// Display next spine item (spine item). public func displayRightDocument() { let delta = triptychView.direction == .rtl ? -1:1 - displaySpineItem(at: triptychView.index + delta) + self.displaySpineItem(at: self.triptychView.index + delta) } /// Display previous document (spine item). public func displayLeftDocument() { let delta = triptychView.direction == .rtl ? -1:1 - displaySpineItem(at: triptychView.index - delta) + self.displaySpineItem(at: self.triptychView.index - delta) } /// Returns the currently presented Publication's identifier. @@ -206,7 +252,7 @@ extension Delegatee: TriptychViewDelegate { public func triptychView(_ view: TriptychView, viewForIndex index: Int, location: BinaryLocation) -> UIView { - let webView = WebView(frame: view.bounds, initialLocation: location) + let webView = WebView(frame: view.bounds, initialLocation: location, pageTransition: parent.pageTransition) webView.direction = view.direction let link = parent.publication.spine[index] @@ -249,3 +295,48 @@ extension Delegatee: TriptychViewDelegate { } } + +extension NavigatorViewController { + + public var contentView: UIView { + return triptychView + } + + func performTriptychViewTransition(commitTransition: @escaping () -> ()) { + switch pageTransition { + case .none: + commitTransition() + case .animated: + fadeTriptychView(alpha: 0) { + commitTransition() + self.fadeTriptychView(alpha: 1, completion: { }) + } + } + } + + /* + This is used when we want to jump to a document with proression. The rendering is sometimes very slow in this case so we have a generous delay before we show the view again. + */ + func performTriptychViewTransitionDelayed(commitTransition: @escaping () -> ()) { + switch pageTransition { + case .none: + commitTransition() + case .animated: + fadeTriptychView(alpha: 0) { + commitTransition() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { + self.fadeTriptychView(alpha: 1, completion: { }) + }) + } + } + } + + private func fadeTriptychView(alpha: CGFloat, completion: @escaping () -> ()) { + UIView.animate(withDuration: 0.15, animations: { + self.triptychView.alpha = alpha + }) { _ in + completion() + } + } +} + diff --git a/r2-navigator-swift/PageTransition.swift b/r2-navigator-swift/PageTransition.swift new file mode 100644 index 00000000..db42a2ab --- /dev/null +++ b/r2-navigator-swift/PageTransition.swift @@ -0,0 +1,17 @@ +// +// PageTransition.swift +// r2-navigator-swift +// +// Created by Jonas Ullström on 2018-11-02. +// +// Copyright 2018 Readium Foundation. All rights reserved. +// Use of this source code is governed by a BSD-style license which is detailed +// in the LICENSE file present in the project repository where this source code is maintained. +// + +import Foundation + +public enum PageTransition { + case none + case animated +} diff --git a/r2-navigator-swift/TriptychView.swift b/r2-navigator-swift/TriptychView.swift index dc2eb7b7..7fd1f0bf 100644 --- a/r2-navigator-swift/TriptychView.swift +++ b/r2-navigator-swift/TriptychView.swift @@ -253,7 +253,6 @@ final class TriptychView: UIView { views = Views.two(firstView: firstView, secondView: secondView) } default: - let currentView = viewForIndex(index, location: leading) if index == 0 { self.views = Views.many( currentView: viewForIndex(index, location: leading), @@ -266,7 +265,7 @@ final class TriptychView: UIView { viewForIndex(index - 1, location: leading))) } else { views = Views.many( - currentView: currentView, + currentView: viewForIndex(index, location: leading), otherViews: Disjunction.both( first: viewForIndex(index - 1, location: trailing), second: viewForIndex(index + 1, location: leading))) @@ -280,10 +279,8 @@ final class TriptychView: UIView { } private func syncSubviews() { + let webViewsBefore = scrollView.subviews.compactMap { $0 as? WebView } scrollView.subviews.forEach({ - if let webview = ($0 as? WebView) { - webview.removeMessageHandlers() - } $0.removeFromSuperview() }) @@ -295,6 +292,10 @@ final class TriptychView: UIView { self.scrollView.addSubview($0) }) } + + webViewsBefore.forEach { + if $0.superview == nil { $0.removeMessageHandlers() } + } } } @@ -398,6 +399,21 @@ extension TriptychView: UIScrollViewDelegate { } } } + + // Set the clamping to .none in scrollViewDidEndScrollingAnimation + // and scrollViewDidEndDragging with decelerate == false, + // to prevent the bug introduced by the workaround in + // scrollViewDidEndDecelerating where the scrollview contentOffset + // is animated. When animating the contentOffset, scrollViewDidScroll + // is called without calling scrollViewDidEndDecelerating afterwards. + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + clamping = .none + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if decelerate { return } + clamping = .none + } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { diff --git a/r2-navigator-swift/UserSettings.swift b/r2-navigator-swift/UserSettings.swift index aac20014..9735b86d 100644 --- a/r2-navigator-swift/UserSettings.swift +++ b/r2-navigator-swift/UserSettings.swift @@ -35,7 +35,6 @@ public class UserSettings { private var pageMargins: Float = 1 private var lineHeight: Float = 1 - public var userSettingsUIPreset: [ReadiumCSSName: Bool]? public let userProperties = UserProperties() private let userDefaults = UserDefaults.standard diff --git a/r2-navigator-swift/WebView.swift b/r2-navigator-swift/WebView.swift index a34cd1fc..e3d68b9b 100644 --- a/r2-navigator-swift/WebView.swift +++ b/r2-navigator-swift/WebView.swift @@ -10,7 +10,6 @@ // import WebKit -import SafariServices import R2Shared @@ -20,7 +19,8 @@ protocol ViewDelegate: class { func handleCenterTap() func publicationIdentifier() -> String? func publicationBaseUrl() -> URL? - func displaySpineItem(with href: String) + func handleTapOnLink(with url: URL) + func handleTapOnInternalLink(with href: String) func documentPageDidChanged(webview: WebView, currentPage: Int ,totalPage: Int) } @@ -31,6 +31,8 @@ final class WebView: WKWebView { var direction: PageProgressionDirection? + var pageTransition: PageTransition + public var initialId: String? // progression and totalPages only work on 'readium-scroll-off' mode public var progression: Double? @@ -48,6 +50,7 @@ final class WebView: WKWebView { public var presentingFixedLayoutContent = false // TMP fix for fxl. + var hasLoadedJsEvents = false let jsEvents = ["leftTap": leftTapped, "centerTap": centerTapped, "rightTap": rightTapped, @@ -61,8 +64,17 @@ final class WebView: WKWebView { internal enum Scroll { case left case right + func proceed(on target: WebView) { - + switch target.pageTransition { + case .none: + evaluateJavascriptForScroll(on: target) + case .animated: + performSwipeTransition(on: target) + } + } + + private func evaluateJavascriptForScroll(on target: WebView) { let dir = target.direction?.rawValue ?? PageProgressionDirection.ltr.rawValue switch self { @@ -80,10 +92,30 @@ final class WebView: WKWebView { }) } } + + private func performSwipeTransition(on target: WebView) { + let scrollView = target.scrollView + switch self { + case .left: + let isAtFirstPageInDocument = scrollView.contentOffset.x == 0 + if !isAtFirstPageInDocument { + return scrollView.scrollToPreviousPage() + } + case .right: + let isAtLastPageInDocument = scrollView.contentOffset.x == scrollView.contentSize.width - scrollView.frame.size.width + if !isAtLastPageInDocument { + return scrollView.scrollToNextPage() + } + } + evaluateJavascriptForScroll(on: target) + } } + + var sizeObservation: NSKeyValueObservation? - init(frame: CGRect, initialLocation: BinaryLocation) { + init(frame: CGRect, initialLocation: BinaryLocation, pageTransition: PageTransition = .none) { self.initialLocation = initialLocation + self.pageTransition = pageTransition super.init(frame: frame, configuration: .init()) isOpaque = false @@ -95,25 +127,22 @@ final class WebView: WKWebView { scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false navigationDelegate = self + uiDelegate = self - scrollView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil) - } - - deinit { - scrollView.removeObserver(self, forKeyPath: "contentSize", context: nil) - } - - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - if keyPath == "contentSize" { - if let value = change?[NSKeyValueChangeKey.newKey] as? CGSize { - // update total pages - let pageCount = Int(value.width / scrollView.frame.size.width); - if totalPages != pageCount { - totalPages = pageCount - viewDelegate?.documentPageDidChanged(webview: self, currentPage: currentPage(), totalPage: pageCount) - } + sizeObservation = scrollView.observe(\.contentSize, options: .new) { (thisScrollView, thisValue) in + // update total pages + guard self.documentLoaded else { return } + guard let newWidth = thisValue.newValue?.width else {return} + let pageWidth = self.scrollView.frame.size.width + if pageWidth == 0.0 {return} // Possible zero value + let pageCount = Int(newWidth / self.scrollView.frame.size.width); + if self.totalPages != pageCount { + self.totalPages = pageCount + self.viewDelegate?.documentPageDidChanged(webview: self, currentPage: self.currentPage(), totalPage: pageCount) } } + + self.alpha = 0 } @available(*, unavailable) @@ -177,6 +206,14 @@ extension WebView { /// - Parameter body: Unused. internal func documentDidLoad(body: String) { documentLoaded = true + + switch pageTransition { + case .none: + alpha = 1 + case .animated: + fadeInWithDelay() + } + applyUserSettingsStyle() scrollToInitialPosition() } @@ -276,10 +313,12 @@ extension WebView: WKScriptMessageHandler { /// Add a message handler for incoming javascript events. internal func addMessageHandlers() { + if hasLoadedJsEvents { return } // Add the message handlers. for eventName in jsEvents.keys { configuration.userContentController.add(self, name: eventName) } + hasLoadedJsEvents = true } // Deinit message handlers (preventing strong reference cycle). @@ -287,6 +326,7 @@ extension WebView: WKScriptMessageHandler { for eventName in jsEvents.keys { configuration.userContentController.removeScriptMessageHandler(forName: eventName) } + hasLoadedJsEvents = false } } @@ -306,15 +346,9 @@ extension WebView: WKNavigationDelegate { let baseUrlString = publicationBaseUrl?.absoluteString { // Internal link. let href = url.absoluteString.replacingOccurrences(of: baseUrlString, with: "") - - viewDelegate?.displaySpineItem(with: href) - } else if url.absoluteString.contains("http") { // TEMPORARY, better checks coming. - // External Link. - let view = SFSafariViewController(url: url) - - UIApplication.shared.keyWindow?.rootViewController?.present(view, - animated: true, - completion: nil) + viewDelegate?.handleTapOnInternalLink(with: href) + } else { + viewDelegate?.handleTapOnLink(with: url) } } } @@ -335,5 +369,58 @@ extension WebView: UIScrollViewDelegate { } return nil } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + scrollView.isUserInteractionEnabled = true + } +} + +extension WebView: WKUIDelegate { + + // The property allowsLinkPreview is default false in iOS9, so it should be safe to use @available(iOS 10.0, *) + @available(iOS 10.0, *) + func webView(_ webView: WKWebView, shouldPreviewElement elementInfo: WKPreviewElementInfo) -> Bool { + let publicationBaseUrl = viewDelegate?.publicationBaseUrl() + let url = elementInfo.linkURL + if url?.host == publicationBaseUrl?.host { + return false + } + return true + } +} + + +private extension UIScrollView { + + func scrollToNextPage() { + moveHorizontalContent(with: bounds.size.width) + } + + func scrollToPreviousPage() { + moveHorizontalContent(with: -bounds.size.width) + } + + private func moveHorizontalContent(with offsetX: CGFloat) { + isUserInteractionEnabled = false + + var newOffset = contentOffset + newOffset.x += offsetX + let rounded = round(newOffset.x / offsetX) * offsetX + newOffset.x = rounded + let area = CGRect.init(origin: newOffset, size: bounds.size) + scrollRectToVisible(area, animated: true) + } +} + +private extension UIView { + + func fadeInWithDelay() { + //TODO: We need to give the CSS and webview time to layout correctly. 0.2 seconds seems like a good value for it to work on an iPhone 5s. Look into solving this better + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + UIView.animate(withDuration: 0.3, animations: { + self.alpha = 1 + }) + } + } }