From af76e44c0f2896c1dbc8328dee6bca72cf24ee41 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Dec 2019 16:55:39 -0700 Subject: [PATCH] Changed ArticleViewController to utilize UIPageViewController to provide gesture based navigation. --- NetNewsWire.xcodeproj/project.pbxproj | 12 +- iOS/AppDelegate.swift | 2 +- iOS/Article/ArticleViewController.swift | 521 +++------------ iOS/Article/ImageTransition.swift | 10 +- iOS/Article/WebViewController.swift | 617 ++++++++++++++++++ ...ewProvider.swift => WebViewProvider.swift} | 6 +- iOS/Base.lproj/Main.storyboard | 41 +- iOS/SceneCoordinator.swift | 83 +-- 8 files changed, 719 insertions(+), 573 deletions(-) create mode 100644 iOS/Article/WebViewController.swift rename iOS/Article/{ArticleViewControllerWebViewProvider.swift => WebViewProvider.swift} (93%) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 3e8b9b83f..dcacef335 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -110,7 +110,7 @@ 51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */; }; 517630042336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; 517630052336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; - 517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */; }; + 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* WebViewProvider.swift */; }; 5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */; }; 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */; }; 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */; }; @@ -148,6 +148,7 @@ 51A9A5F32380DE530033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */; }; 51A9A60A2382FD240033AADF /* PoppableGestureRecognizerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */; }; + 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AB8AB223B7F4C6008F147D /* WebViewController.swift */; }; 51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; }; 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; }; 51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; }; @@ -1292,7 +1293,7 @@ 516AE9DE2372269A007DEEAA /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = ""; }; 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerController.swift; sourceTree = ""; }; 517630032336215100E15FFF /* main.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main.js; sourceTree = ""; }; - 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewControllerWebViewProvider.swift; sourceTree = ""; }; + 517630222336657E00E15FFF /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = ""; }; 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = ""; }; 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = ""; }; 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshInterval.swift; sourceTree = ""; }; @@ -1320,6 +1321,7 @@ 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = ""; }; 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = ""; }; 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = ""; }; + 51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = ""; }; 51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; @@ -1950,7 +1952,8 @@ isa = PBXGroup; children = ( 51C4527E2265092C00C03939 /* ArticleViewController.swift */, - 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */, + 51AB8AB223B7F4C6008F147D /* WebViewController.swift */, + 517630222336657E00E15FFF /* WebViewProvider.swift */, 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */, 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */, 5142192923522B5500E07E2C /* ImageViewController.swift */, @@ -3835,7 +3838,7 @@ 511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */, 51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */, 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */, - 517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */, + 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */, 51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */, 51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */, 51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */, @@ -3843,6 +3846,7 @@ 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */, 51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */, 51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */, + 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */, 516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */, 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */, 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */, diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 18f53e55f..27964ddf1 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -59,7 +59,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD appDelegate = self // Force lazy initialization of the web view provider so that it can warm up the queue of prepared web views - let _ = ArticleViewControllerWebViewProvider.shared + let _ = WebViewProvider.shared AccountManager.shared = AccountManager() NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index c791206a8..968e7f2cb 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -12,32 +12,20 @@ import Account import Articles import SafariServices -enum ArticleViewState: Equatable { - case noSelection - case multipleSelection - case loading - case article(Article) - case extracted(Article, ExtractedArticle) -} - class ArticleViewController: UIViewController { - private struct MessageName { - static let imageWasClicked = "imageWasClicked" - static let imageWasShown = "imageWasShown" - } - @IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem! @IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem! @IBOutlet private weak var nextArticleBarButtonItem: UIBarButtonItem! @IBOutlet private weak var readBarButtonItem: UIBarButtonItem! @IBOutlet private weak var starBarButtonItem: UIBarButtonItem! @IBOutlet private weak var actionBarButtonItem: UIBarButtonItem! - @IBOutlet private weak var webViewContainer: UIView! - @IBOutlet private weak var showNavigationView: UIView! - @IBOutlet private weak var showToolbarView: UIView! - @IBOutlet private weak var showNavigationViewConstraint: NSLayoutConstraint! - @IBOutlet private weak var showToolbarViewConstraint: NSLayoutConstraint! + + private var pageViewController: UIPageViewController! + + private var currentWebViewController: WebViewController? { + return pageViewController?.viewControllers?.first as? WebViewController + } private var articleExtractorButton: ArticleExtractorButton = { let button = ArticleExtractorButton(type: .system) @@ -46,44 +34,23 @@ class ArticleViewController: UIViewController { return button }() - private var webView: WKWebView! - private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self) private var isFullScreenAvailable: Bool { return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed } - private lazy var transition = ImageTransition(controller: self) - private var clickedImageCompletion: (() -> Void)? weak var coordinator: SceneCoordinator! - var state: ArticleViewState = .noSelection { + var article: Article? { didSet { - if state != oldValue { - updateUI() - reloadHTML() + if let controller = currentWebViewController, controller.article != article { + controller.article = article + DispatchQueue.main.async { + // You have to set the view controller to clear out the UIPageViewController child controller cache. + // You also have to do it in an async call or you will get a strange assertion error. + self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) + } } - } - } - - var restoreOffset = 0 - - var currentArticle: Article? { - switch state { - case .article(let article): - return article - case .extracted(let article, _): - return article - default: - return nil - } - } - - var articleExtractorButtonState: ArticleExtractorButtonState { - get { - return articleExtractorButton.buttonState - } - set { - articleExtractorButton.buttonState = newValue + updateUI() } } @@ -92,26 +59,11 @@ class ArticleViewController: UIViewController { return keyboardManager.keyCommands } - deinit { - if webView != nil { - webView?.evaluateJavaScript("cancelImageLoad();") - webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked) - webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown) - webView.removeFromSuperview() - ArticleViewControllerWebViewProvider.shared.enqueueWebView(webView) - webView = nil - } - } - override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) let fullScreenTapZone = UIView() @@ -125,43 +77,29 @@ class ArticleViewController: UIViewController { articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside) toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6) - showNavigationView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) - showToolbarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) + pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:]) + pageViewController.delegate = self + pageViewController.dataSource = self - ArticleViewControllerWebViewProvider.shared.dequeueWebView() { webView in - - self.webView = webView - self.webViewContainer.addChildAndPin(webView) - - webView.translatesAutoresizingMaskIntoConstraints = false - self.webViewContainer.addSubview(webView) - NSLayoutConstraint.activate([ - self.webViewContainer.leadingAnchor.constraint(equalTo: webView.leadingAnchor), - self.webViewContainer.trailingAnchor.constraint(equalTo: webView.trailingAnchor), - self.webViewContainer.topAnchor.constraint(equalTo: webView.topAnchor), - self.webViewContainer.bottomAnchor.constraint(equalTo: webView.bottomAnchor) - ]) - - webView.navigationDelegate = self - webView.uiDelegate = self - self.configureContextMenuInteraction() - - webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) - webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) - - // Even though page.html should be loaded into this webview, we have to do it again - // to work around this bug: http://www.openradar.me/22855188 - let url = Bundle.main.url(forResource: "page", withExtension: "html")! - webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) - - } + view.addSubview(pageViewController.view) + addChild(pageViewController!) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor), + view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor), + view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor) + ]) + + let controller = createWebViewController(article) + pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) + updateUI() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if AppDefaults.articleFullscreenEnabled { - hideBars() + currentWebViewController?.hideBars() } } @@ -177,7 +115,7 @@ class ArticleViewController: UIViewController { func updateUI() { - guard let article = currentArticle else { + guard let article = article else { articleExtractorButton.isEnabled = false nextUnreadBarButtonItem.isEnabled = false prevArticleBarButtonItem.isEnabled = false @@ -205,41 +143,6 @@ class ArticleViewController: UIViewController { } - func reloadHTML() { - - let style = ArticleStylesManager.shared.currentStyle - let rendering: ArticleRenderer.Rendering - - switch state { - case .noSelection: - rendering = ArticleRenderer.noSelectionHTML(style: style) - case .multipleSelection: - rendering = ArticleRenderer.multipleSelectionHTML(style: style) - case .loading: - rendering = ArticleRenderer.loadingHTML(style: style) - case .article(let article): - rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true) - case .extracted(let article, let extractedArticle): - rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: true) - } - - let templateData = TemplateData(style: rendering.style, body: rendering.html) - - let encoder = JSONEncoder() - var render = "error();" - if let data = try? encoder.encode(templateData) { - let json = String(data: data, encoding: .utf8)! - render = "render(\(json), \(restoreOffset));" - } - - restoreOffset = 0 - - ArticleViewControllerWebViewProvider.shared.articleIconSchemeHandler.currentArticle = currentArticle - webView?.scrollView.setZoomScale(1.0, animated: false) - webView?.evaluateJavaScript(render) - - } - // MARK: Notifications @objc dynamic func unreadCountDidChange(_ notification: Notification) { @@ -250,49 +153,33 @@ class ArticleViewController: UIViewController { guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set else { return } - guard let currentArticle = currentArticle else { + guard let article = article else { return } - if articleIDs.contains(currentArticle.articleID) { + if articleIDs.contains(article.articleID) { updateUI() } } - @objc func webFeedIconDidBecomeAvailable(_ note: Notification) { - reloadArticleImage() - } - - @objc func avatarDidBecomeAvailable(_ note: Notification) { - reloadArticleImage() - } - - @objc func faviconDidBecomeAvailable(_ note: Notification) { - reloadArticleImage() - } - - @objc func contentSizeCategoryDidChange(_ note: Notification) { - reloadHTML() - } - @objc func willEnterForeground(_ note: Notification) { // The toolbar will come back on you if you don't hide it again if AppDefaults.articleFullscreenEnabled { - hideBars() + currentWebViewController?.hideBars() } } // MARK: Actions @objc func didTapNavigationBar() { - hideBars() + currentWebViewController?.hideBars() } @objc func showBars(_ sender: Any) { - showBars() + currentWebViewController?.showBars() } @IBAction func toggleArticleExtractor(_ sender: Any) { - coordinator.toggleArticleExtractor() + currentWebViewController?.toggleArticleExtractor() } @IBAction func nextUnread(_ sender: Any) { @@ -316,7 +203,7 @@ class ArticleViewController: UIViewController { } @IBAction func showActivityDialog(_ sender: Any) { - showActivityDialog() + currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem) } // MARK: Keyboard Shortcuts @@ -327,331 +214,81 @@ class ArticleViewController: UIViewController { // MARK: API func focus() { - webView.becomeFirstResponder() + currentWebViewController?.focus() } func finalScrollPosition() -> CGFloat { - return webView.scrollView.contentSize.height - webView.scrollView.bounds.size.height + webView.scrollView.contentInset.bottom + return currentWebViewController?.finalScrollPosition() ?? 0.0 } func canScrollDown() -> Bool { - return webView.scrollView.contentOffset.y < finalScrollPosition() + return currentWebViewController?.canScrollDown() ?? false } func scrollPageDown() { - let scrollToY: CGFloat = { - let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.bounds.size.height - let final = finalScrollPosition() - return fullScroll < final ? fullScroll : final - }() - - let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView) - let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY) - webView.scrollView.setContentOffset(scrollToPoint, animated: true) - } - - func hideClickedImage() { - webView?.evaluateJavaScript("hideClickedImage();") - } - - func showClickedImage(completion: @escaping () -> Void) { - clickedImageCompletion = completion - webView?.evaluateJavaScript("showClickedImage();") + currentWebViewController?.scrollPageDown() } func fullReload() { - if let offset = webView?.scrollView.contentOffset.y { - restoreOffset = Int(offset) - webView?.reload() + currentWebViewController?.fullReload() + } + +} + +// MARK: WebViewControllerDelegate + +extension ArticleViewController: WebViewControllerDelegate { + func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) { + if webViewController === currentWebViewController { + articleExtractorButton.buttonState = buttonState } } } -// MARK: UIContextMenuInteractionDelegate +// MARK: UIPageViewControllerDataSource -extension ArticleViewController: UIContextMenuInteractionDelegate { - func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { +extension ArticleViewController: UIPageViewControllerDataSource { - return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in - guard let self = self else { return nil } - var actions = [UIAction]() - - if let action = self.prevArticleAction() { - actions.append(action) - } - if let action = self.nextArticleAction() { - actions.append(action) - } - actions.append(self.toggleReadAction()) - actions.append(self.toggleStarredAction()) - if let action = self.nextUnreadArticleAction() { - actions.append(action) - } - actions.append(self.toggleArticleExtractorAction()) - actions.append(self.shareAction()) - - return UIMenu(title: "", children: actions) - } - } - - func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - coordinator.showBrowserForCurrentArticle() - } - -} - -// MARK: WKNavigationDelegate - -extension ArticleViewController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - - if navigationAction.navigationType == .linkActivated { - - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - if components?.scheme == "http" || components?.scheme == "https" { - let vc = SFSafariViewController(url: url) - present(vc, animated: true) - decisionHandler(.cancel) - } else { - decisionHandler(.allow) - } - - } else { - - decisionHandler(.allow) - + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let article = coordinator.prevArticle else { + return nil } - - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - self.updateUI() - self.reloadHTML() + return createWebViewController(article) } -} - -// MARK: WKUIDelegate - -extension ArticleViewController: WKUIDelegate { - func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) { - // We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the - // link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get - // the link from the elementInfo above and transition to SFSafariViewController instead of launching - // Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_(ツ)_/¯ - } -} - -// MARK: WKScriptMessageHandler - -extension ArticleViewController: WKScriptMessageHandler { - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - switch message.name { - case MessageName.imageWasShown: - clickedImageCompletion?() - case MessageName.imageWasClicked: - imageWasClicked(body: message.body as? String) - default: - return + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let article = coordinator.nextArticle else { + return nil } + return createWebViewController(article) } } -class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler { - - // We need to wrap a message handler to prevent a circlular reference - private weak var handler: WKScriptMessageHandler? - - init(_ handler: WKScriptMessageHandler) { - self.handler = handler +// MARK: UIPageViewControllerDelegate + +extension ArticleViewController: UIPageViewControllerDelegate { + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + guard finished, completed else { return } + guard let article = currentWebViewController?.article else { return } + coordinator.selectArticle(article) + articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off } - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - handler?.userContentController(userContentController, didReceive: message) - } - -} - -// MARK: UIViewControllerTransitioningDelegate - -extension ArticleViewController: UIViewControllerTransitioningDelegate { - - func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { - transition.presenting = true - return transition - } - - func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { - transition.presenting = false - return transition - } -} - -// MARK: JSON - -private struct TemplateData: Codable { - let style: String - let body: String -} - -private struct ImageClickMessage: Codable { - let x: Float - let y: Float - let width: Float - let height: Float - let imageURL: String } // MARK: Private private extension ArticleViewController { - func reloadArticleImage() { - webView?.evaluateJavaScript("reloadArticleImage()") - } - - func imageWasClicked(body: String?) { - guard let body = body, - let data = body.data(using: .utf8), - let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data), - let range = clickMessage.imageURL.range(of: ";base64,") - else { return } - - let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound)) - if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) { - - let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top - let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height)) - transition.originFrame = webView.convert(rect, to: nil) - - if navigationController?.navigationBar.isHidden ?? false { - transition.maskFrame = webView.convert(webView.frame, to: nil) - } else { - transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil) - } - - transition.originImage = image - - coordinator.showFullScreenImage(image: image, transitioningDelegate: self) - } - } - - func showActivityDialog() { - guard let preferredLink = currentArticle?.preferredLink, let url = URL(string: preferredLink) else { - return - } - - let itemSource = ArticleActivityItemSource(url: url, subject: currentArticle!.title) - let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil) - activityViewController.popoverPresentationController?.barButtonItem = actionBarButtonItem - present(activityViewController, animated: true) - } - - func showBars() { - if isFullScreenAvailable { - AppDefaults.articleFullscreenEnabled = false - coordinator.showStatusBar() - showNavigationViewConstraint.constant = 0 - showToolbarViewConstraint.constant = 0 - navigationController?.setNavigationBarHidden(false, animated: true) - navigationController?.setToolbarHidden(false, animated: true) - configureContextMenuInteraction() - } - } - - func hideBars() { - if isFullScreenAvailable { - AppDefaults.articleFullscreenEnabled = true - coordinator.hideStatusBar() - showNavigationViewConstraint.constant = 44.0 - showToolbarViewConstraint.constant = 44.0 - navigationController?.setNavigationBarHidden(true, animated: true) - navigationController?.setToolbarHidden(true, animated: true) - configureContextMenuInteraction() - } - } - - func configureContextMenuInteraction() { - if isFullScreenAvailable { - if navigationController?.isNavigationBarHidden ?? false { - webView?.addInteraction(contextMenuInteraction) - } else { - webView?.removeInteraction(contextMenuInteraction) - } - } - - } - - func contextMenuPreviewProvider() -> UIViewController { - let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self) - previewProvider.article = currentArticle - return previewProvider - } - - func prevArticleAction() -> UIAction? { - guard coordinator.isPrevArticleAvailable else { return nil } - let title = NSLocalizedString("Previous Article", comment: "Previous Article") - return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in - self?.coordinator.selectPrevArticle() - } - } - - func nextArticleAction() -> UIAction? { - guard coordinator.isNextArticleAvailable else { return nil } - let title = NSLocalizedString("Next Article", comment: "Next Article") - return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in - self?.coordinator.selectNextArticle() - } - } - - func toggleReadAction() -> UIAction { - let read = currentArticle?.status.read ?? false - let title = read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read") - let readImage = read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage - return UIAction(title: title, image: readImage) { [weak self] action in - self?.coordinator.toggleReadForCurrentArticle() - } - } - - func toggleStarredAction() -> UIAction { - let starred = currentArticle?.status.starred ?? false - let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred") - let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage - return UIAction(title: title, image: starredImage) { [weak self] action in - self?.coordinator.toggleStarredForCurrentArticle() - } - } - - func nextUnreadArticleAction() -> UIAction? { - guard coordinator.isAnyUnreadAvailable else { return nil } - let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article") - return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in - self?.coordinator.selectNextUnread() - } - } - - func toggleArticleExtractorAction() -> UIAction { - let extracted = articleExtractorButton.buttonState == .on - let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View") - let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF - return UIAction(title: title, image: extractorImage) { [weak self] action in - self?.coordinator.toggleArticleExtractor() - } - } - - func shareAction() -> UIAction { - let title = NSLocalizedString("Share", comment: "Share") - return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in - self?.showActivityDialog() - } + func createWebViewController(_ article: Article?) -> WebViewController { + let controller = WebViewController() + controller.coordinator = coordinator + controller.delegate = self + controller.article = article + return controller } } diff --git a/iOS/Article/ImageTransition.swift b/iOS/Article/ImageTransition.swift index 0c17a59b9..0608b3c7b 100644 --- a/iOS/Article/ImageTransition.swift +++ b/iOS/Article/ImageTransition.swift @@ -10,15 +10,15 @@ import UIKit class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning { - private weak var articleController: ArticleViewController? + private weak var webViewController: WebViewController? private let duration = 0.4 var presenting = true var originFrame: CGRect! var maskFrame: CGRect! var originImage: UIImage! - init(controller: ArticleViewController) { - self.articleController = controller + init(controller: WebViewController) { + self.webViewController = controller } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { @@ -44,7 +44,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning { transitionContext.containerView.backgroundColor = AppAssets.fullScreenBackgroundColor transitionContext.containerView.addSubview(imageView) - articleController?.hideClickedImage() + webViewController?.hideClickedImage() UIView.animate( withDuration: duration, @@ -93,7 +93,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning { animations: { imageView.frame = self.originFrame }, completion: { _ in - self.articleController?.showClickedImage() { + self.webViewController?.showClickedImage() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { imageView.removeFromSuperview() transitionContext.completeTransition(true) diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift new file mode 100644 index 000000000..a79a649d9 --- /dev/null +++ b/iOS/Article/WebViewController.swift @@ -0,0 +1,617 @@ +// +// WebViewController.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 12/28/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit +import WebKit +import Account +import Articles +import SafariServices + +protocol WebViewControllerDelegate: class { + func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState) +} + +class WebViewController: UIViewController { + + private struct MessageName { + static let imageWasClicked = "imageWasClicked" + static let imageWasShown = "imageWasShown" + } + + private var topShowBarsView: UIView! + private var bottomShowBarsView: UIView! + private var topShowBarsViewConstraint: NSLayoutConstraint! + private var bottomShowBarsViewConstraint: NSLayoutConstraint! + + private var webView: WKWebView! + private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self) + private var isFullScreenAvailable: Bool { + return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed + } + private lazy var transition = ImageTransition(controller: self) + private var clickedImageCompletion: (() -> Void)? + + private var articleExtractor: ArticleExtractor? = nil + private var extractedArticle: ExtractedArticle? + private var isShowingExtractedArticle = false { + didSet { + if isShowingExtractedArticle != oldValue { + reloadHTML() + } + } + } + + var articleExtractorButtonState: ArticleExtractorButtonState = .off { + didSet { + delegate.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState) + } + } + + weak var coordinator: SceneCoordinator! + weak var delegate: WebViewControllerDelegate! + + var article: Article? { + didSet { + stopArticleExtractor() + if article?.webFeed?.isArticleExtractorAlwaysOn ?? false { + startArticleExtractor() + } + if oldValue != nil && article != oldValue { + reloadHTML() + } + } + } + + var restoreOffset = 0 + + deinit { + if webView != nil { + webView?.evaluateJavaScript("cancelImageLoad();") + webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked) + webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown) + webView.removeFromSuperview() + WebViewProvider.shared.enqueueWebView(webView) + webView = nil + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil) + + WebViewProvider.shared.dequeueWebView() { webView in + + // Add the webview + self.webView = webView + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(webView) + NSLayoutConstraint.activate([ + self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor), + self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor), + self.view.topAnchor.constraint(equalTo: webView.topAnchor), + self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor) + ]) + + self.configureTopShowBarsView() + self.configureBottomShowBarsView() + + // Configure the webview + webView.navigationDelegate = self + webView.uiDelegate = self + self.configureContextMenuInteraction() + + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) + + // Even though page.html should be loaded into this webview, we have to do it again + // to work around this bug: http://www.openradar.me/22855188 + let url = Bundle.main.url(forResource: "page", withExtension: "html")! + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + } + + } + + func reloadHTML() { + + let style = ArticleStylesManager.shared.currentStyle + let rendering: ArticleRenderer.Rendering + + if let articleExtractor = articleExtractor, articleExtractor.state == .processing { + rendering = ArticleRenderer.loadingHTML(style: style) + } else if let article = article, let extractedArticle = extractedArticle { + if isShowingExtractedArticle { + rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: true) + } else { + rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true) + } + } else if let article = article { + rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true) + } else { + rendering = ArticleRenderer.noSelectionHTML(style: style) + } + + let templateData = TemplateData(style: rendering.style, body: rendering.html) + + let encoder = JSONEncoder() + var render = "error();" + if let data = try? encoder.encode(templateData) { + let json = String(data: data, encoding: .utf8)! + render = "render(\(json), \(restoreOffset));" + } + + restoreOffset = 0 + + WebViewProvider.shared.articleIconSchemeHandler.currentArticle = article + webView?.scrollView.setZoomScale(1.0, animated: false) + webView?.evaluateJavaScript(render) + + } + + // MARK: Notifications + + @objc func webFeedIconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func avatarDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func faviconDidBecomeAvailable(_ note: Notification) { + reloadArticleImage() + } + + @objc func contentSizeCategoryDidChange(_ note: Notification) { + reloadHTML() + } + + // MARK: Actions + + @objc func showBars(_ sender: Any) { + showBars() + } + + // MARK: API + + func focus() { + webView.becomeFirstResponder() + } + + func finalScrollPosition() -> CGFloat { + return webView.scrollView.contentSize.height - webView.scrollView.bounds.size.height + webView.scrollView.contentInset.bottom + } + + func canScrollDown() -> Bool { + return webView.scrollView.contentOffset.y < finalScrollPosition() + } + + func scrollPageDown() { + let scrollToY: CGFloat = { + let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.bounds.size.height + let final = finalScrollPosition() + return fullScroll < final ? fullScroll : final + }() + + let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView) + let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY) + webView.scrollView.setContentOffset(scrollToPoint, animated: true) + } + + func hideClickedImage() { + webView?.evaluateJavaScript("hideClickedImage();") + } + + func showClickedImage(completion: @escaping () -> Void) { + clickedImageCompletion = completion + webView?.evaluateJavaScript("showClickedImage();") + } + + func fullReload() { + if let offset = webView?.scrollView.contentOffset.y { + restoreOffset = Int(offset) + webView?.reload() + } + } + + func showBars() { + if isFullScreenAvailable { + AppDefaults.articleFullscreenEnabled = false + coordinator.showStatusBar() + topShowBarsViewConstraint.constant = 0 + bottomShowBarsViewConstraint.constant = 0 + navigationController?.setNavigationBarHidden(false, animated: true) + navigationController?.setToolbarHidden(false, animated: true) + configureContextMenuInteraction() + } + } + + func hideBars() { + if isFullScreenAvailable { + AppDefaults.articleFullscreenEnabled = true + coordinator.hideStatusBar() + topShowBarsViewConstraint.constant = -44.0 + bottomShowBarsViewConstraint.constant = 44.0 + navigationController?.setNavigationBarHidden(true, animated: true) + navigationController?.setToolbarHidden(true, animated: true) + configureContextMenuInteraction() + } + } + + func toggleArticleExtractor() { + + guard let article = article else { + return + } + + guard articleExtractor?.state != .processing else { + stopArticleExtractor() + return + } + + guard !isShowingExtractedArticle else { + isShowingExtractedArticle = false + articleExtractorButtonState = .off + return + } + + if let articleExtractor = articleExtractor { + if article.preferredLink == articleExtractor.articleLink { + isShowingExtractedArticle = true + articleExtractorButtonState = .on + } + } else { + startArticleExtractor() + } + + } + + func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) { + guard let preferredLink = article?.preferredLink, let url = URL(string: preferredLink) else { + return + } + + let itemSource = ArticleActivityItemSource(url: url, subject: article!.title) + let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil) + activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem + present(activityViewController, animated: true) + } + +} + +// MARK: ArticleExtractorDelegate + +extension WebViewController: ArticleExtractorDelegate { + + func articleExtractionDidFail(with: Error) { + stopArticleExtractor() + articleExtractorButtonState = .error + } + + func articleExtractionDidComplete(extractedArticle: ExtractedArticle) { + if articleExtractor?.state != .cancelled { + self.extractedArticle = extractedArticle + isShowingExtractedArticle = true + articleExtractorButtonState = .on + } + } + +} + +// MARK: UIContextMenuInteractionDelegate + +extension WebViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + + return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in + guard let self = self else { return nil } + var actions = [UIAction]() + + if let action = self.prevArticleAction() { + actions.append(action) + } + if let action = self.nextArticleAction() { + actions.append(action) + } + actions.append(self.toggleReadAction()) + actions.append(self.toggleStarredAction()) + if let action = self.nextUnreadArticleAction() { + actions.append(action) + } + actions.append(self.toggleArticleExtractorAction()) + actions.append(self.shareAction()) + + return UIMenu(title: "", children: actions) + } + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + coordinator.showBrowserForCurrentArticle() + } + +} + +// MARK: WKNavigationDelegate + +extension WebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + if navigationAction.navigationType == .linkActivated { + + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if components?.scheme == "http" || components?.scheme == "https" { + let vc = SFSafariViewController(url: url) + present(vc, animated: true) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + + } else { + + decisionHandler(.allow) + + } + + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.reloadHTML() + } + +} + +// MARK: WKUIDelegate + +extension WebViewController: WKUIDelegate { + func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) { + // We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the + // link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get + // the link from the elementInfo above and transition to SFSafariViewController instead of launching + // Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_(ツ)_/¯ + } +} + +// MARK: WKScriptMessageHandler + +extension WebViewController: WKScriptMessageHandler { + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + switch message.name { + case MessageName.imageWasShown: + clickedImageCompletion?() + case MessageName.imageWasClicked: + imageWasClicked(body: message.body as? String) + default: + return + } + } + +} + +class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler { + + // We need to wrap a message handler to prevent a circlular reference + private weak var handler: WKScriptMessageHandler? + + init(_ handler: WKScriptMessageHandler) { + self.handler = handler + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + handler?.userContentController(userContentController, didReceive: message) + } + +} + +// MARK: UIViewControllerTransitioningDelegate + +extension WebViewController: UIViewControllerTransitioningDelegate { + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + transition.presenting = true + return transition + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + transition.presenting = false + return transition + } +} + +// MARK: JSON + +private struct TemplateData: Codable { + let style: String + let body: String +} + +private struct ImageClickMessage: Codable { + let x: Float + let y: Float + let width: Float + let height: Float + let imageURL: String +} + +// MARK: Private + +private extension WebViewController { + + func startArticleExtractor() { + if let link = article?.preferredLink, let extractor = ArticleExtractor(link) { + extractor.delegate = self + extractor.process() + articleExtractor = extractor + articleExtractorButtonState = .animated + } + } + + func stopArticleExtractor() { + articleExtractor?.cancel() + articleExtractor = nil + isShowingExtractedArticle = false + articleExtractorButtonState = .off + } + + func reloadArticleImage() { + webView?.evaluateJavaScript("reloadArticleImage()") + } + + func imageWasClicked(body: String?) { + guard let body = body, + let data = body.data(using: .utf8), + let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data), + let range = clickMessage.imageURL.range(of: ";base64,") + else { return } + + let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound)) + if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) { + + let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top + let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height)) + transition.originFrame = webView.convert(rect, to: nil) + + if navigationController?.navigationBar.isHidden ?? false { + transition.maskFrame = webView.convert(webView.frame, to: nil) + } else { + transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil) + } + + transition.originImage = image + + coordinator.showFullScreenImage(image: image, transitioningDelegate: self) + } + } + + func configureTopShowBarsView() { + topShowBarsView = UIView() + topShowBarsView.backgroundColor = .clear + topShowBarsView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(topShowBarsView) + + if AppDefaults.articleFullscreenEnabled { + topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0) + } else { + topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0) + } + + NSLayoutConstraint.activate([ + topShowBarsViewConstraint, + view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor), + topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0) + ]) + topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) + } + + func configureBottomShowBarsView() { + bottomShowBarsView = UIView() + topShowBarsView.backgroundColor = .clear + bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomShowBarsView) + if AppDefaults.articleFullscreenEnabled { + bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0) + } else { + bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0) + } + NSLayoutConstraint.activate([ + bottomShowBarsViewConstraint, + view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor), + bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0) + ]) + bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) + } + + func configureContextMenuInteraction() { + if isFullScreenAvailable { + if navigationController?.isNavigationBarHidden ?? false { + webView?.addInteraction(contextMenuInteraction) + } else { + webView?.removeInteraction(contextMenuInteraction) + } + } + } + + func contextMenuPreviewProvider() -> UIViewController { + let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self) + previewProvider.article = article + return previewProvider + } + + func prevArticleAction() -> UIAction? { + guard coordinator.isPrevArticleAvailable else { return nil } + let title = NSLocalizedString("Previous Article", comment: "Previous Article") + return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in + self?.coordinator.selectPrevArticle() + } + } + + func nextArticleAction() -> UIAction? { + guard coordinator.isNextArticleAvailable else { return nil } + let title = NSLocalizedString("Next Article", comment: "Next Article") + return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in + self?.coordinator.selectNextArticle() + } + } + + func toggleReadAction() -> UIAction { + let read = article?.status.read ?? false + let title = read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read") + let readImage = read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage + return UIAction(title: title, image: readImage) { [weak self] action in + self?.coordinator.toggleReadForCurrentArticle() + } + } + + func toggleStarredAction() -> UIAction { + let starred = article?.status.starred ?? false + let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred") + let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage + return UIAction(title: title, image: starredImage) { [weak self] action in + self?.coordinator.toggleStarredForCurrentArticle() + } + } + + func nextUnreadArticleAction() -> UIAction? { + guard coordinator.isAnyUnreadAvailable else { return nil } + let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article") + return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in + self?.coordinator.selectNextUnread() + } + } + + func toggleArticleExtractorAction() -> UIAction { + let extracted = articleExtractorButtonState == .on + let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View") + let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF + return UIAction(title: title, image: extractorImage) { [weak self] action in + self?.toggleArticleExtractor() + } + } + + func shareAction() -> UIAction { + let title = NSLocalizedString("Share", comment: "Share") + return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in + self?.showActivityDialog() + } + } + +} diff --git a/iOS/Article/ArticleViewControllerWebViewProvider.swift b/iOS/Article/WebViewProvider.swift similarity index 93% rename from iOS/Article/ArticleViewControllerWebViewProvider.swift rename to iOS/Article/WebViewProvider.swift index d8c34aad5..2c90e4505 100644 --- a/iOS/Article/ArticleViewControllerWebViewProvider.swift +++ b/iOS/Article/WebViewProvider.swift @@ -1,5 +1,5 @@ // -// ArticleViewControllerWebViewProvider.swift +// WebViewProvider.swift // NetNewsWire-iOS // // Created by Maurice Parker on 9/21/19. @@ -11,9 +11,9 @@ import WebKit /// WKWebView has an awful behavior of a flash to white on first load when in dark mode. /// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle. -class ArticleViewControllerWebViewProvider: NSObject, WKNavigationDelegate { +class WebViewProvider: NSObject, WKNavigationDelegate { - static let shared = ArticleViewControllerWebViewProvider() + static let shared = WebViewProvider() let articleIconSchemeHandler = ArticleIconSchemeHandler() diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index c16a87a55..458cc26a2 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -15,39 +15,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -75,7 +43,7 @@ - + @@ -117,15 +85,10 @@ - + - - - - - diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 76955cd20..b49017f42 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -34,9 +34,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var activityManager = ActivityManager() - private var isShowingExtractedArticle = false - private var articleExtractor: ArticleExtractor? = nil - private var rootSplitViewController: RootSplitViewController! private var masterNavigationController: UINavigationController! private var masterFeedViewController: MasterFeedViewController! @@ -723,7 +720,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func selectArticle(_ article: Article?, animated: Bool = false) { guard article != currentArticle else { return } - stopArticleExtractor() currentArticle = article activityManager.reading(feed: timelineFeed, article: article) @@ -733,7 +729,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { masterNavigationController.popViewController(animated: animated) } } else { - articleViewController?.state = .noSelection + articleViewController?.article = nil } masterTimelineViewController?.updateArticleSelection(animated: animated) return @@ -747,13 +743,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } masterTimelineViewController?.updateArticleSelection(animated: animated) - - if article!.webFeed?.isArticleExtractorAlwaysOn ?? false { - startArticleExtractorForCurrentLink() - currentArticleViewController.state = .loading - } else { - currentArticleViewController.state = .article(article!) - } + currentArticleViewController.article = article markArticles(Set([article!]), statusKey: .read, flag: true) @@ -1006,37 +996,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { rootSplitViewController.present(imageVC, animated: true) } - func toggleArticleExtractor() { - - guard let article = currentArticle else { - return - } - - guard articleExtractor?.state != .processing else { - stopArticleExtractor() - articleViewController?.state = .article(article) - return - } - - guard !isShowingExtractedArticle else { - isShowingExtractedArticle = false - articleViewController?.articleExtractorButtonState = .off - articleViewController?.state = .article(article) - return - } - - if let articleExtractor = articleExtractor, let extractedArticle = articleExtractor.article { - if currentArticle?.preferredLink == articleExtractor.articleLink { - isShowingExtractedArticle = true - articleViewController?.articleExtractorButtonState = .on - articleViewController?.state = .extracted(article, extractedArticle) - } - } else { - startArticleExtractorForCurrentLink() - } - - } - func homePageURLForFeed(_ indexPath: IndexPath) -> URL? { guard let node = nodeFor(indexPath), let feed = node.representedObject as? WebFeed, @@ -1154,7 +1113,6 @@ extension SceneCoordinator: UINavigationControllerDelegate { // This happens when we are going to the next unread and we need to grab another timeline to continue. The // ArticleViewController will be pushed, but we will breifly show the Timeline. Don't clear things out when that happens. if viewController === masterTimelineViewController && !isThreePanelMode && rootSplitViewController.isCollapsed && !isArticleViewControllerPending { - stopArticleExtractor() currentArticle = nil masterTimelineViewController?.updateArticleSelection(animated: animated) activityManager.invalidateReading() @@ -1170,25 +1128,6 @@ extension SceneCoordinator: UINavigationControllerDelegate { } -// MARK: ArticleExtractorDelegate - -extension SceneCoordinator: ArticleExtractorDelegate { - - func articleExtractionDidFail(with: Error) { - stopArticleExtractor() - articleViewController?.articleExtractorButtonState = .error - } - - func articleExtractionDidComplete(extractedArticle: ExtractedArticle) { - if let article = currentArticle, articleExtractor?.state != .cancelled { - isShowingExtractedArticle = true - articleViewController?.state = .extracted(article, extractedArticle) - articleViewController?.articleExtractorButtonState = .on - } - } - -} - // MARK: Private private extension SceneCoordinator { @@ -1533,22 +1472,6 @@ private extension SceneCoordinator { // MARK: Fetching Articles - func startArticleExtractorForCurrentLink() { - if let link = currentArticle?.preferredLink, let extractor = ArticleExtractor(link) { - extractor.delegate = self - extractor.process() - articleExtractor = extractor - articleViewController?.articleExtractorButtonState = .animated - } - } - - func stopArticleExtractor() { - articleExtractor?.cancel() - articleExtractor = nil - isShowingExtractedArticle = false - articleViewController?.articleExtractorButtonState = .off - } - func emptyTheTimeline() { if !articles.isEmpty { timelineMiddleIndexPath = nil @@ -1723,6 +1646,8 @@ private extension SceneCoordinator { // We have to do a full reload when installing an article controller. We may have changed color contexts // and need to update the article colors. An example is in dark mode. Split screen doesn't use true black // like darkmode usually does. + + // TODO: This should probably only happen to recycled article controllers articleController.fullReload() return articleController