Files
NetNewsWire/iOS/Article/ArticleViewController.swift
Stuart Breckenridge 630972033b Aa as More Menu
More now includes feed inspector's actions to manage notifications and reader view.

Subtitles removed.

First time full screen view shows, an alert is displayed to explain how to exit full screen.
2022-02-14 07:43:51 +08:00

647 lines
24 KiB
Swift

//
// ArticleViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/8/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import Account
import Articles
import SafariServices
class ArticleViewController: UIViewController, MainControllerIdentifiable {
typealias State = (extractedArticle: ExtractedArticle?,
isShowingExtractedArticle: Bool,
articleExtractorButtonState: ArticleExtractorButtonState,
windowScrollY: Int)
@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 appearanceBarButtonItem: UIBarButtonItem!
@IBOutlet private var searchBar: ArticleSearchBar!
@IBOutlet private var searchBarBottomConstraint: NSLayoutConstraint!
private var defaultControls: [UIBarButtonItem]?
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
return pageViewController?.viewControllers?.first as? WebViewController
}
private var articleExtractorButton: ArticleExtractorButton = {
let button = ArticleExtractorButton(type: .system)
button.frame = CGRect(x: 0, y: 0, width: 44.0, height: 44.0)
button.setImage(AppAssets.articleExtractorOff, for: .normal)
return button
}()
var mainControllerIdentifer = MainControllerIdentifier.article
weak var coordinator: SceneCoordinator!
private let poppableDelegate = PoppableGestureRecognizerDelegate()
var article: Article? {
didSet {
if let controller = currentWebViewController, controller.article != article {
controller.setArticle(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)
}
}
updateUI()
}
}
var restoreScrollPosition: (isShowingExtractedArticle: Bool, articleWindowScrollY: Int)? {
didSet {
if let rsp = restoreScrollPosition {
currentWebViewController?.setScrollPosition(isShowingExtractedArticle: rsp.isShowingExtractedArticle, articleWindowScrollY: rsp.articleWindowScrollY)
}
}
}
var currentState: State? {
guard let controller = currentWebViewController else { return nil}
return State(extractedArticle: controller.extractedArticle,
isShowingExtractedArticle: controller.isShowingExtractedArticle,
articleExtractorButtonState: controller.articleExtractorButtonState,
windowScrollY: controller.windowScrollY)
}
var restoreState: State?
private let keyboardManager = KeyboardManager(type: .detail)
override var keyCommands: [UIKeyCommand]? {
return keyboardManager.keyCommands
}
var currentUnreadCount: Int = 0 {
didSet {
updateUnreadCountIndicator()
}
}
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(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadDueToThemeChange(_:)), name: .CurrentArticleThemeDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(configureAppearanceMenu(_:)), name: .ArticleThemeNamesDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(updateUnreadCountIndicator(_:)), name: UIDevice.orientationDidChangeNotification, object: nil)
articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside)
toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6)
if let parentNavController = navigationController?.parent as? UINavigationController {
poppableDelegate.navigationController = parentNavController
parentNavController.interactivePopGestureRecognizer?.delegate = poppableDelegate
}
navigationItem.leftItemsSupplementBackButton = true
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
pageViewController.delegate = self
pageViewController.dataSource = self
// This code is to disallow paging if we scroll from the left edge. If this code is removed
// PoppableGestureRecognizerDelegate will allow us to both navigate back and page back at the
// same time. That is really weird when it happens.
let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.delegate = self
pageViewController.scrollViewInsidePageControl?.addGestureRecognizer(panGestureRecognizer)
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
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: WebViewController
if let state = restoreState {
controller = createWebViewController(article, updateView: false)
controller.extractedArticle = state.extractedArticle
controller.isShowingExtractedArticle = state.isShowingExtractedArticle
controller.articleExtractorButtonState = state.articleExtractorButtonState
controller.windowScrollY = state.windowScrollY
} else {
controller = createWebViewController(article, updateView: true)
}
if let rsp = restoreScrollPosition {
controller.setScrollPosition(isShowingExtractedArticle: rsp.isShowingExtractedArticle, articleWindowScrollY: rsp.articleWindowScrollY)
}
articleExtractorButton.buttonState = controller.articleExtractorButtonState
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
// Search bar
searchBar.translatesAutoresizingMaskIntoConstraints = false
NotificationCenter.default.addObserver(self, selector: #selector(beginFind(_:)), name: .FindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(endFind(_:)), name: .EndFindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil)
searchBar.delegate = self
view.bringSubviewToFront(searchBar)
configureAppearanceMenu()
updateUI()
}
override func viewWillAppear(_ animated: Bool) {
navigationController?.isToolbarHidden = false
if AppDefaults.shared.articleFullscreenEnabled {
currentWebViewController?.hideBars()
}
super.viewWillAppear(animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if searchBar != nil && !searchBar.isHidden {
endFind()
}
}
override func viewSafeAreaInsetsDidChange() {
// This will animate if the show/hide bars animation is happening.
view.layoutIfNeeded()
}
func updateUI() {
guard let article = article else {
articleExtractorButton.isEnabled = false
nextUnreadBarButtonItem.isEnabled = false
prevArticleBarButtonItem.isEnabled = false
nextArticleBarButtonItem.isEnabled = false
readBarButtonItem.isEnabled = false
starBarButtonItem.isEnabled = false
actionBarButtonItem.isEnabled = false
appearanceBarButtonItem.isEnabled = false
return
}
nextUnreadBarButtonItem.isEnabled = coordinator.isAnyUnreadAvailable
prevArticleBarButtonItem.isEnabled = coordinator.isPrevArticleAvailable
nextArticleBarButtonItem.isEnabled = coordinator.isNextArticleAvailable
readBarButtonItem.isEnabled = true
starBarButtonItem.isEnabled = true
appearanceBarButtonItem.isEnabled = true
let permalinkPresent = article.preferredLink != nil
var isFeedProvider = false
if let webfeed = article.webFeed {
isFeedProvider = webfeed.isFeedProvider
}
articleExtractorButton.isEnabled = permalinkPresent && !AppDefaults.shared.isDeveloperBuild && !isFeedProvider
actionBarButtonItem.isEnabled = permalinkPresent
if article.status.read {
readBarButtonItem.image = AppAssets.circleOpenImage
readBarButtonItem.isEnabled = article.isAvailableToMarkUnread
readBarButtonItem.accLabelText = NSLocalizedString("Mark Article Unread", comment: "Mark Article Unread")
} else {
readBarButtonItem.image = AppAssets.circleClosedImage
readBarButtonItem.isEnabled = true
readBarButtonItem.accLabelText = NSLocalizedString("Selected - Mark Article Unread", comment: "Selected - Mark Article Unread")
}
if article.status.starred {
starBarButtonItem.image = AppAssets.starClosedImage
starBarButtonItem.accLabelText = NSLocalizedString("Selected - Star Article", comment: "Selected - Star Article")
} else {
starBarButtonItem.image = AppAssets.starOpenImage
starBarButtonItem.accLabelText = NSLocalizedString("Star Article", comment: "Star Article")
}
}
override func contentScrollView(for edge: NSDirectionalRectEdge) -> UIScrollView? {
return currentWebViewController?.webView?.scrollView
}
@objc
func configureAppearanceMenu(_ sender: Any? = nil) {
var themeActions = [UIAction]()
for themeName in ArticleThemesManager.shared.themeNames {
let action = UIAction(title: themeName,
image: nil,
identifier: nil,
discoverabilityTitle: nil,
attributes: [],
state: ArticleThemesManager.shared.currentThemeName == themeName ? .on : .off,
handler: { action in
ArticleThemesManager.shared.currentThemeName = themeName
})
themeActions.append(action)
}
let defaultThemeAction = UIAction(title: NSLocalizedString("Default", comment: "Default"), image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: ArticleThemesManager.shared.currentThemeName == AppDefaults.defaultThemeName ? .on : .off) { _ in
ArticleThemesManager.shared.currentThemeName = AppDefaults.defaultThemeName
}
let defaultThemeMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [defaultThemeAction])
let customThemeMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: themeActions)
let themeMenu = UIMenu(title: "Theme", image: AppAssets.themeImage, identifier: nil, options: .singleSelection, children: [ defaultThemeMenu, customThemeMenu])
var appearanceChildren: [UIMenuElement] = [themeMenu]
if let currentWebViewController = currentWebViewController {
if currentWebViewController.isFullScreenAvailable {
let fullScreenAction = UIAction(title: NSLocalizedString("Full Screen", comment: "Full Screen"),
image: UIImage(systemName: "arrow.up.backward.and.arrow.down.forward"),
identifier: nil,
discoverabilityTitle: nil,
attributes: [],
state: .off) { [weak self] _ in
self?.currentWebViewController?.hideBars()
if AppDefaults.shared.hasUsedFullScreenPreviously == false {
let alert = UIAlertController(title: NSLocalizedString("Exit Full Screen", comment: "Full Screen"),
message: NSLocalizedString("To exit Full Screen mode tap the top of the screen.\n\nYou'll only see this message once.", comment: "Full screen explainer."),
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .default, handler: { _ in
AppDefaults.shared.hasUsedFullScreenPreviously = true
}))
self?.present(alert, animated: true, completion: nil)
}
}
appearanceChildren.append(fullScreenAction)
}
}
var feedManagementChildren = [UIMenuElement]()
if let feed = article?.webFeed {
let extractorOn = feed.isArticleExtractorAlwaysOn ?? false
let readerAction = UIAction(title: NSLocalizedString("Always Use Reader View", comment: "Always Use Reader View"),
image: AppAssets.articleExtractorOffSF,
identifier: nil,
discoverabilityTitle: nil,
attributes: [],
state: extractorOn ? .on : .off) { [weak self] _ in
if feed.isArticleExtractorAlwaysOn == nil {
feed.isArticleExtractorAlwaysOn = true
} else {
feed.isArticleExtractorAlwaysOn?.toggle()
}
self?.configureAppearanceMenu()
}
feedManagementChildren.append(readerAction)
let notifyOn = feed.isNotifyAboutNewArticles ?? false
let notifyAction = UIAction(title: NSLocalizedString("Notify About New Articles", comment: "Notify About New Articles"),
image: AppAssets.appBadgeImage,
identifier: nil,
discoverabilityTitle: nil,
attributes: [],
state: notifyOn ? .on : .off) { [weak self] _ in
if feed.isNotifyAboutNewArticles == nil {
feed.isNotifyAboutNewArticles = true
} else {
feed.isNotifyAboutNewArticles?.toggle()
}
self?.configureAppearanceMenu()
}
feedManagementChildren.append(notifyAction)
}
let appearanceMenu = UIMenu(title: NSLocalizedString("Article Appearance", comment: "Appearance"), image: nil, identifier: nil, options: .displayInline, children: appearanceChildren)
let feedMgmtMenu = UIMenu(title: NSLocalizedString("Feed Management", comment: "Feed Management"), image: nil , identifier: nil, options: .displayInline, children: feedManagementChildren)
let menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [appearanceMenu, feedMgmtMenu])
appearanceBarButtonItem.menu = menu
}
@objc
func reloadDueToThemeChange(_ notification: Notification) {
currentWebViewController?.fullReload()
configureAppearanceMenu()
}
/// Updates the indicator count in the navigation bar.
/// For iPhone, this indicator is visible if the unread count is > 0.
/// For iPad, this indicator is visible if it is in `portrait` or `unknown`
/// orientation, **and** the unread count is > 0.
/// - Parameter sender: `Any` (optional)
@objc
public func updateUnreadCountIndicator(_ sender: Any? = nil) {
if UIDevice.current.userInterfaceIdiom == .phone {
if currentUnreadCount > 0 {
let unreadCountView = MasterTimelineUnreadCountView(frame: .zero)
unreadCountView.unreadCount = currentUnreadCount
unreadCountView.setFrameIfNotEqual(CGRect(x: 0, y: 0, width: unreadCountView.intrinsicContentSize.width, height: unreadCountView.intrinsicContentSize.height))
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: unreadCountView)
} else {
navigationItem.leftBarButtonItem = nil
}
} else {
if UIDevice.current.orientation.isPortrait || !UIDevice.current.orientation.isValidInterfaceOrientation {
if currentUnreadCount > 0 {
let unreadCountView = MasterTimelineUnreadCountView(frame: .zero)
unreadCountView.unreadCount = currentUnreadCount
unreadCountView.setFrameIfNotEqual(CGRect(x: 0, y: 0, width: unreadCountView.intrinsicContentSize.width, height: unreadCountView.intrinsicContentSize.height))
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: unreadCountView)
} else {
navigationItem.leftBarButtonItem = nil
}
} else {
navigationItem.leftBarButtonItem = nil
}
}
}
// MARK: Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) {
updateUI()
}
@objc func statusesDidChange(_ note: Notification) {
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return
}
guard let article = article else {
return
}
if articleIDs.contains(article.articleID) {
updateUI()
}
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
currentWebViewController?.fullReload()
}
@objc func willEnterForeground(_ note: Notification) {
// The toolbar will come back on you if you don't hide it again
if AppDefaults.shared.articleFullscreenEnabled {
currentWebViewController?.hideBars()
}
}
// MARK: Actions
@objc func showBars(_ sender: Any) {
currentWebViewController?.showBars()
}
@IBAction func toggleArticleExtractor(_ sender: Any) {
currentWebViewController?.toggleArticleExtractor()
}
@IBAction func nextUnread(_ sender: Any) {
coordinator.selectNextUnread()
}
@IBAction func prevArticle(_ sender: Any) {
coordinator.selectPrevArticle()
}
@IBAction func nextArticle(_ sender: Any) {
coordinator.selectNextArticle()
}
@IBAction func toggleRead(_ sender: Any) {
coordinator.toggleReadForCurrentArticle()
}
@IBAction func toggleStar(_ sender: Any) {
coordinator.toggleStarredForCurrentArticle()
}
@IBAction func showActivityDialog(_ sender: Any) {
currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem)
}
@objc func toggleReaderView(_ sender: Any?) {
currentWebViewController?.toggleArticleExtractor()
}
// MARK: Keyboard Shortcuts
@objc func navigateToTimeline(_ sender: Any?) {
coordinator.navigateToTimeline()
}
// MARK: API
func focus() {
currentWebViewController?.focus()
}
func canScrollDown() -> Bool {
return currentWebViewController?.canScrollDown() ?? false
}
func canScrollUp() -> Bool {
return currentWebViewController?.canScrollUp() ?? false
}
func scrollPageDown() {
currentWebViewController?.scrollPageDown()
}
func scrollPageUp() {
currentWebViewController?.scrollPageUp()
}
func stopArticleExtractorIfProcessing() {
currentWebViewController?.stopArticleExtractorIfProcessing()
}
func openInAppBrowser() {
currentWebViewController?.openInAppBrowser()
}
func setScrollPosition(isShowingExtractedArticle: Bool, articleWindowScrollY: Int) {
currentWebViewController?.setScrollPosition(isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
}
}
// MARK: Find in Article
public extension Notification.Name {
static let FindInArticle = Notification.Name("FindInArticle")
static let EndFindInArticle = Notification.Name("EndFindInArticle")
}
extension ArticleViewController: SearchBarDelegate {
func searchBar(_ searchBar: ArticleSearchBar, textDidChange searchText: String) {
currentWebViewController?.searchText(searchText) {
found in
searchBar.resultsCount = found.count
if let index = found.index {
searchBar.selectedResult = index + 1
}
}
}
func doneWasPressed(_ searchBar: ArticleSearchBar) {
NotificationCenter.default.post(name: .EndFindInArticle, object: nil)
}
func nextWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult < searchBar.resultsCount {
currentWebViewController?.selectNextSearchResult()
searchBar.selectedResult += 1
}
}
func previousWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult > 1 {
currentWebViewController?.selectPreviousSearchResult()
searchBar.selectedResult -= 1
}
}
}
extension ArticleViewController {
@objc func beginFind(_ _: Any? = nil) {
searchBar.isHidden = false
navigationController?.setToolbarHidden(true, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height
searchBar.becomeFirstResponder()
}
@objc func endFind(_ _: Any? = nil) {
searchBar.resignFirstResponder()
searchBar.isHidden = true
navigationController?.setToolbarHidden(false, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = 0
currentWebViewController?.endSearch()
}
@objc func keyboardWillChangeFrame(_ notification: Notification) {
if !searchBar.isHidden,
let duration = notification.userInfo?[UIWindow.keyboardAnimationDurationUserInfoKey] as? Double,
let curveRaw = notification.userInfo?[UIWindow.keyboardAnimationCurveUserInfoKey] as? UInt,
let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect {
let curve = UIView.AnimationOptions(rawValue: curveRaw)
let newHeight = view.safeAreaLayoutGuide.layoutFrame.maxY - frame.minY
currentWebViewController?.additionalSafeAreaInsets.bottom = newHeight + searchBar.frame.height + 10
self.searchBarBottomConstraint.constant = newHeight
UIView.animate(withDuration: duration, delay: 0, options: curve, animations: {
self.view.layoutIfNeeded()
})
}
}
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
if webViewController === currentWebViewController {
articleExtractorButton.buttonState = buttonState
}
}
}
// MARK: UIPageViewControllerDataSource
extension ArticleViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findPrevArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findNextArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
}
// 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, animations: [.select, .scroll, .navigation])
articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
}
}
// MARK: UIGestureRecognizerDelegate
extension ArticleViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let point = gestureRecognizer.location(in: nil)
if point.x > 40 {
return true
}
return false
}
}
// MARK: Private
private extension ArticleViewController {
func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController {
let controller = WebViewController()
controller.coordinator = coordinator
controller.delegate = self
controller.setArticle(article, updateView: updateView)
return controller
}
}