Changed ArticleViewController to utilize UIPageViewController to provide gesture based navigation.

This commit is contained in:
Maurice Parker 2019-12-31 16:55:39 -07:00
parent 0a29535412
commit af76e44c0f
8 changed files with 719 additions and 573 deletions

View File

@ -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 = "<group>"; };
51707438232AA97100A461A3 /* ShareFolderPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerController.swift; sourceTree = "<group>"; };
517630032336215100E15FFF /* main.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main.js; sourceTree = "<group>"; };
517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewControllerWebViewProvider.swift; sourceTree = "<group>"; };
517630222336657E00E15FFF /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = "<group>"; };
5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; };
5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = "<group>"; };
5183CCE4226F4DFA0010922C /* RefreshInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshInterval.swift; sourceTree = "<group>"; };
@ -1320,6 +1321,7 @@
51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = "<group>"; };
51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = "<group>"; };
51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = "<group>"; };
51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = "<group>"; };
51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = "<group>"; };
51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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)

View File

@ -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<String> 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
}
}

View File

@ -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)

View File

@ -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()
}
}
}

View File

@ -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()

View File

@ -15,39 +15,7 @@
<view key="view" contentMode="scaleToFill" id="svH-Pt-448">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DNb-lt-KzC">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iEi-hX-TYy">
<rect key="frame" x="0.0" y="813" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="xX2-AK-xJX"/>
</constraints>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="A7j-8T-DqE">
<rect key="frame" x="0.0" y="-12" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="3HX-Dm-bA6"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="VUw-jc-0yf" firstAttribute="bottom" secondItem="iEi-hX-TYy" secondAttribute="top" id="4fZ-pn-fmB"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="top" secondItem="svH-Pt-448" secondAttribute="top" id="Bfh-RL-m4d"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="Feu-hj-K01"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="bottom" secondItem="svH-Pt-448" secondAttribute="bottom" id="FfW-6G-Bcp"/>
<constraint firstItem="VUw-jc-0yf" firstAttribute="trailing" secondItem="iEi-hX-TYy" secondAttribute="trailing" id="Ij6-ri-sBN"/>
<constraint firstItem="iEi-hX-TYy" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="Muc-gr-S7o"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="QJ5-Ne-ndd"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="bottom" secondItem="VUw-jc-0yf" secondAttribute="top" id="b2h-zZ-xwi"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="ezE-0p-35X"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="wny-M6-akA"/>
</constraints>
<viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/>
</view>
<toolbarItems>
@ -75,7 +43,7 @@
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Next Unread"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="nextUnread:" destination="JEX-9P-axG" id="USD-hC-C6z"/>
<action selector="nextUnread:" destination="JEX-9P-axG" id="nI3-pz-tc8"/>
</connections>
</barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="vAq-iW-Yyo"/>
@ -117,15 +85,10 @@
<connections>
<outlet property="actionBarButtonItem" destination="9Ut-5B-JKP" id="9bO-kz-cTz"/>
<outlet property="nextArticleBarButtonItem" destination="2qz-M5-Yhk" id="IQd-jx-qEr"/>
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="xJr-5y-p1N"/>
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="Ekf-My-AHN"/>
<outlet property="prevArticleBarButtonItem" destination="v4j-fq-23N" id="Gny-Oh-cQa"/>
<outlet property="readBarButtonItem" destination="hy0-LS-MzE" id="BzM-x9-tuj"/>
<outlet property="showNavigationView" destination="A7j-8T-DqE" id="D59-3C-HmS"/>
<outlet property="showNavigationViewConstraint" destination="b2h-zZ-xwi" id="CaG-8F-5kF"/>
<outlet property="showToolbarView" destination="iEi-hX-TYy" id="zoa-h3-H8b"/>
<outlet property="showToolbarViewConstraint" destination="4fZ-pn-fmB" id="ayD-Mq-kft"/>
<outlet property="starBarButtonItem" destination="wU4-eH-wC9" id="Z8Q-Lt-dKk"/>
<outlet property="webViewContainer" destination="DNb-lt-KzC" id="Fc1-Ae-pWK"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/>

View File

@ -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