mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Create MainWindow folder to match Mac folder structure.
This commit is contained in:
96
iOS/MainWindow/Article/ArticleExtractorButton.swift
Normal file
96
iOS/MainWindow/Article/ArticleExtractorButton.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// ArticleExtractorButton.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 9/24/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum ArticleExtractorButtonState {
|
||||
case error
|
||||
case animated
|
||||
case on
|
||||
case off
|
||||
}
|
||||
|
||||
final class ArticleExtractorButton: UIButton {
|
||||
|
||||
private var animatedLayer: CALayer?
|
||||
|
||||
var buttonState: ArticleExtractorButtonState = .off {
|
||||
didSet {
|
||||
if buttonState != oldValue {
|
||||
switch buttonState {
|
||||
case .error:
|
||||
stripAnimatedSublayer()
|
||||
setImage(AppImage.articleExtractorError, for: .normal)
|
||||
case .animated:
|
||||
setImage(nil, for: .normal)
|
||||
setNeedsLayout()
|
||||
case .on:
|
||||
stripAnimatedSublayer()
|
||||
setImage(AppImage.articleExtractorOn, for: .normal)
|
||||
case .off:
|
||||
stripAnimatedSublayer()
|
||||
setImage(AppImage.articleExtractorOff, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var accessibilityLabel: String? {
|
||||
get {
|
||||
switch buttonState {
|
||||
case .error:
|
||||
return NSLocalizedString("Error - Reader View", comment: "Error - Reader View")
|
||||
case .animated:
|
||||
return NSLocalizedString("Processing - Reader View", comment: "Processing - Reader View")
|
||||
case .on:
|
||||
return NSLocalizedString("Selected - Reader View", comment: "Selected - Reader View")
|
||||
case .off:
|
||||
return NSLocalizedString("Reader View", comment: "Reader View")
|
||||
}
|
||||
}
|
||||
set {
|
||||
super.accessibilityLabel = newValue
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard case .animated = buttonState else {
|
||||
return
|
||||
}
|
||||
stripAnimatedSublayer()
|
||||
addAnimatedSublayer(to: layer)
|
||||
}
|
||||
|
||||
private func stripAnimatedSublayer() {
|
||||
animatedLayer?.removeFromSuperlayer()
|
||||
}
|
||||
|
||||
private func addAnimatedSublayer(to hostedLayer: CALayer) {
|
||||
let image1 = AppImage.articleExtractorOffTinted.cgImage!
|
||||
let image2 = AppImage.articleExtractorOnTinted.cgImage!
|
||||
let images = [image1, image2, image1]
|
||||
|
||||
animatedLayer = CALayer()
|
||||
let imageSize = AppImage.articleExtractorOff.size
|
||||
animatedLayer!.bounds = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.height)
|
||||
animatedLayer!.position = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
hostedLayer.addSublayer(animatedLayer!)
|
||||
|
||||
let animation = CAKeyframeAnimation(keyPath: "contents")
|
||||
animation.calculationMode = CAAnimationCalculationMode.linear
|
||||
animation.keyTimes = [0, 0.5, 1]
|
||||
animation.duration = 2
|
||||
animation.values = images as [Any]
|
||||
animation.repeatCount = HUGE
|
||||
|
||||
animatedLayer!.add(animation, forKey: "contents")
|
||||
}
|
||||
|
||||
}
|
||||
175
iOS/MainWindow/Article/ArticleSearchBar.swift
Normal file
175
iOS/MainWindow/Article/ArticleSearchBar.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// ArticleSearchBar.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brian Sanders on 5/8/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc protocol SearchBarDelegate: NSObjectProtocol {
|
||||
@objc optional func nextWasPressed(_ searchBar: ArticleSearchBar)
|
||||
@objc optional func previousWasPressed(_ searchBar: ArticleSearchBar)
|
||||
@objc optional func doneWasPressed(_ searchBar: ArticleSearchBar)
|
||||
@objc optional func searchBar(_ searchBar: ArticleSearchBar, textDidChange: String)
|
||||
}
|
||||
|
||||
@IBDesignable final class ArticleSearchBar: UIStackView {
|
||||
var searchField: UISearchTextField!
|
||||
var nextButton: UIButton!
|
||||
var prevButton: UIButton!
|
||||
var background: UIView!
|
||||
|
||||
weak private var resultsLabel: UILabel!
|
||||
|
||||
var resultsCount: UInt = 0 {
|
||||
didSet {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
var selectedResult: UInt = 1 {
|
||||
didSet {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: SearchBarDelegate?
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return [UIKeyCommand(title: "Exit Find", action: #selector(donePressed(_:)), input: UIKeyCommand.inputEscape)]
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
layer.backgroundColor = UIColor(named: "barBackgroundColor")?.cgColor ?? UIColor.white.cgColor
|
||||
isOpaque = true
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: searchField)
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
if resultsCount > 0 {
|
||||
let format = NSLocalizedString("%d of %d", comment: "Results selection and count")
|
||||
resultsLabel.text = String.localizedStringWithFormat(format, selectedResult, resultsCount)
|
||||
} else {
|
||||
resultsLabel.text = NSLocalizedString("No results", comment: "No results")
|
||||
}
|
||||
|
||||
nextButton.isEnabled = selectedResult < resultsCount
|
||||
prevButton.isEnabled = resultsCount > 0 && selectedResult > 1
|
||||
}
|
||||
|
||||
@discardableResult override func becomeFirstResponder() -> Bool {
|
||||
searchField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
@discardableResult override func resignFirstResponder() -> Bool {
|
||||
searchField.resignFirstResponder()
|
||||
}
|
||||
|
||||
override var isFirstResponder: Bool {
|
||||
searchField.isFirstResponder
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ArticleSearchBar {
|
||||
func commonInit() {
|
||||
isLayoutMarginsRelativeArrangement = true
|
||||
alignment = .center
|
||||
spacing = 8
|
||||
layoutMargins.left = 8
|
||||
layoutMargins.right = 8
|
||||
|
||||
background = UIView(frame: bounds)
|
||||
background.backgroundColor = .systemGray5
|
||||
background.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
addSubview(background)
|
||||
|
||||
let doneButton = UIButton()
|
||||
doneButton.setTitle(NSLocalizedString("Done", comment: "Done"), for: .normal)
|
||||
doneButton.setTitleColor(UIColor.label, for: .normal)
|
||||
doneButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
|
||||
doneButton.isAccessibilityElement = true
|
||||
doneButton.addTarget(self, action: #selector(donePressed), for: .touchUpInside)
|
||||
doneButton.isEnabled = true
|
||||
addArrangedSubview(doneButton)
|
||||
|
||||
let resultsLabel = UILabel()
|
||||
searchField = UISearchTextField()
|
||||
searchField.autocapitalizationType = .none
|
||||
searchField.autocorrectionType = .no
|
||||
searchField.returnKeyType = .search
|
||||
searchField.delegate = self
|
||||
|
||||
resultsLabel.font = .systemFont(ofSize: UIFont.smallSystemFontSize)
|
||||
resultsLabel.textColor = .secondaryLabel
|
||||
resultsLabel.text = ""
|
||||
resultsLabel.textAlignment = .right
|
||||
resultsLabel.adjustsFontSizeToFitWidth = true
|
||||
searchField.rightView = resultsLabel
|
||||
searchField.rightViewMode = .always
|
||||
|
||||
self.resultsLabel = resultsLabel
|
||||
addArrangedSubview(searchField)
|
||||
|
||||
prevButton = UIButton(type: .system)
|
||||
prevButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||
prevButton.accessibilityLabel = "Previous Result"
|
||||
prevButton.isAccessibilityElement = true
|
||||
prevButton.addTarget(self, action: #selector(previousPressed), for: .touchUpInside)
|
||||
addArrangedSubview(prevButton)
|
||||
|
||||
nextButton = UIButton(type: .system)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
nextButton.accessibilityLabel = "Next Result"
|
||||
nextButton.isAccessibilityElement = true
|
||||
nextButton.addTarget(self, action: #selector(nextPressed), for: .touchUpInside)
|
||||
addArrangedSubview(nextButton)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ArticleSearchBar {
|
||||
|
||||
@objc func textDidChange(_ notification: Notification) {
|
||||
delegate?.searchBar?(self, textDidChange: searchField.text ?? "")
|
||||
|
||||
if searchField.text?.isEmpty ?? true {
|
||||
searchField.rightViewMode = .never
|
||||
} else {
|
||||
searchField.rightViewMode = .always
|
||||
}
|
||||
}
|
||||
|
||||
@objc func nextPressed() {
|
||||
delegate?.nextWasPressed?(self)
|
||||
}
|
||||
|
||||
@objc func previousPressed() {
|
||||
delegate?.previousWasPressed?(self)
|
||||
}
|
||||
|
||||
@objc func donePressed(_ _: Any? = nil) {
|
||||
delegate?.doneWasPressed?(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension ArticleSearchBar: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
delegate?.nextWasPressed?(self)
|
||||
return false
|
||||
}
|
||||
}
|
||||
510
iOS/MainWindow/Article/ArticleViewController.swift
Normal file
510
iOS/MainWindow/Article/ArticleViewController.swift
Normal file
@@ -0,0 +1,510 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
final class ArticleViewController: UIViewController {
|
||||
|
||||
struct State {
|
||||
let extractedArticle: ExtractedArticle?
|
||||
let isShowingExtractedArticle: Bool
|
||||
let articleExtractorButtonState: ArticleExtractorButtonState
|
||||
let 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 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(AppImage.articleExtractorOff, for: .normal)
|
||||
return button
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let fullScreenTapZone = UIView()
|
||||
NSLayoutConstraint.activate([
|
||||
fullScreenTapZone.widthAnchor.constraint(equalToConstant: 150),
|
||||
fullScreenTapZone.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
fullScreenTapZone.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
|
||||
navigationItem.titleView = fullScreenTapZone
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if AppDefaults.logicalArticleFullscreenEnabled {
|
||||
controller.hideBars()
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
let hideToolbars = AppDefaults.logicalArticleFullscreenEnabled
|
||||
if hideToolbars {
|
||||
currentWebViewController?.hideBars()
|
||||
} else {
|
||||
currentWebViewController?.showBars()
|
||||
}
|
||||
super.viewWillAppear(animated)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(true)
|
||||
coordinator.isArticleViewControllerPending = false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override func willTransition(to newCollection: UITraitCollection, with coordinator: any UIViewControllerTransitionCoordinator) {
|
||||
// We only want to show bars when rotating to horizontalSizeClass == .regular
|
||||
// (i.e., big) iPhones to resolve crash #4483.
|
||||
if UIDevice.current.userInterfaceIdiom == .phone && newCollection.horizontalSizeClass == .regular {
|
||||
currentWebViewController?.showBars()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
nextUnreadBarButtonItem.isEnabled = coordinator.isAnyUnreadAvailable
|
||||
prevArticleBarButtonItem.isEnabled = coordinator.isPrevArticleAvailable
|
||||
nextArticleBarButtonItem.isEnabled = coordinator.isNextArticleAvailable
|
||||
readBarButtonItem.isEnabled = true
|
||||
starBarButtonItem.isEnabled = true
|
||||
|
||||
let permalinkPresent = article.preferredLink != nil
|
||||
articleExtractorButton.isEnabled = permalinkPresent && !AppDefaults.isDeveloperBuild
|
||||
actionBarButtonItem.isEnabled = permalinkPresent
|
||||
|
||||
if article.status.read {
|
||||
readBarButtonItem.image = AppImage.circleOpen
|
||||
readBarButtonItem.isEnabled = article.isAvailableToMarkUnread
|
||||
readBarButtonItem.accLabelText = NSLocalizedString("Mark Article Unread", comment: "Mark Article Unread")
|
||||
} else {
|
||||
readBarButtonItem.image = AppImage.circleClosed
|
||||
readBarButtonItem.isEnabled = true
|
||||
readBarButtonItem.accLabelText = NSLocalizedString("Selected - Mark Article Unread", comment: "Selected - Mark Article Unread")
|
||||
}
|
||||
|
||||
if article.status.starred {
|
||||
starBarButtonItem.image = AppImage.starClosed
|
||||
starBarButtonItem.accLabelText = NSLocalizedString("Selected - Star Article", comment: "Selected - Star Article")
|
||||
} else {
|
||||
starBarButtonItem.image = AppImage.starOpen
|
||||
starBarButtonItem.accLabelText = NSLocalizedString("Star Article", comment: "Star Article")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.logicalArticleFullscreenEnabled {
|
||||
currentWebViewController?.hideBars()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc func didTapNavigationBar() {
|
||||
currentWebViewController?.hideBars()
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// ContextMenuPreviewViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 11/25/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Articles
|
||||
|
||||
/// Used in the WebView when in full screen mode.
|
||||
final class ContextMenuPreviewViewController: UIViewController {
|
||||
|
||||
@IBOutlet weak var blogNameLabel: UILabel!
|
||||
@IBOutlet weak var blogAuthorLabel: UILabel!
|
||||
@IBOutlet weak var articleTitleLabel: UILabel!
|
||||
@IBOutlet weak var dateTimeLabel: UILabel!
|
||||
|
||||
var article: Article?
|
||||
|
||||
init(article: Article?) {
|
||||
self.article = article
|
||||
super.init(nibName: "ContextMenuPreviewViewController", bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
blogNameLabel.text = article?.feed?.nameForDisplay ?? ""
|
||||
blogAuthorLabel.text = article?.byline()
|
||||
articleTitleLabel.text = article?.title ?? ""
|
||||
|
||||
let icon = IconView()
|
||||
icon.iconImage = article?.iconImage()
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(icon)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
icon.widthAnchor.constraint(equalToConstant: 48),
|
||||
icon.heightAnchor.constraint(equalToConstant: 48),
|
||||
icon.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
icon.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20)
|
||||
])
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .long
|
||||
dateFormatter.timeStyle = .medium
|
||||
if let article {
|
||||
dateTimeLabel.text = dateFormatter.string(from: article.logicalDatePublished)
|
||||
}
|
||||
|
||||
// When in landscape the context menu preview will force this controller into a tiny
|
||||
// view space. If it is documented anywhere what that is, I haven't found it. This
|
||||
// set of magic numbers is what I worked out by testing a variety of phones.
|
||||
|
||||
let width: CGFloat
|
||||
let heightPadding: CGFloat
|
||||
if view.bounds.width > view.bounds.height {
|
||||
width = 260
|
||||
heightPadding = 16
|
||||
view.widthAnchor.constraint(equalToConstant: width).isActive = true
|
||||
} else {
|
||||
width = view.bounds.width
|
||||
heightPadding = 8
|
||||
}
|
||||
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
preferredContentSize = CGSize(width: width, height: dateTimeLabel.frame.maxY + heightPadding)
|
||||
}
|
||||
|
||||
}
|
||||
103
iOS/MainWindow/Article/ContextMenuPreviewViewController.xib
Normal file
103
iOS/MainWindow/Article/ContextMenuPreviewViewController.xib
Normal file
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ContextMenuPreviewViewController" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="articleTitleLabel" destination="euU-Ij-5LS" id="SaE-2x-Lmt"/>
|
||||
<outlet property="blogAuthorLabel" destination="yxD-bn-7rJ" id="e6N-4a-9gZ"/>
|
||||
<outlet property="blogNameLabel" destination="VwJ-Ji-WmN" id="rvk-Ef-eXK"/>
|
||||
<outlet property="dateTimeLabel" destination="tZE-CV-RS5" id="hvo-tH-m4w"/>
|
||||
<outlet property="view" destination="reY-47-9dn" id="Uul-on-hM9"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<view contentMode="scaleToFill" id="reY-47-9dn">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VwJ-Ji-WmN">
|
||||
<rect key="frame" x="20" y="8.0000000000000018" width="87" height="20.666666666666671"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||
<color key="textColor" name="primaryAccentColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Author" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yxD-bn-7rJ">
|
||||
<rect key="frame" x="20" y="36.666666666666664" width="90" height="21"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Article Title" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="euU-Ij-5LS">
|
||||
<rect key="frame" x="20" y="74.666666666666671" width="136" height="33.666666666666671"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tZE-CV-RS5">
|
||||
<rect key="frame" x="20" y="116.33333333333333" width="44" height="20.333333333333329"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xxd-ws-vsN">
|
||||
<rect key="frame" x="325" y="8" width="48" height="48"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="48" id="3zG-fk-K1j"/>
|
||||
<constraint firstAttribute="height" constant="48" id="LUF-bb-x6X"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="PEP-UU-rG9">
|
||||
<rect key="frame" x="20" y="65.666666666666671" width="353" height="1"/>
|
||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="1" id="K9J-bi-mdi"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Hb5-Na-kgr"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="tZE-CV-RS5" secondAttribute="bottom" constant="8" id="3rq-hS-dk8"/>
|
||||
<constraint firstItem="yxD-bn-7rJ" firstAttribute="top" secondItem="VwJ-Ji-WmN" secondAttribute="bottom" constant="8" id="5xH-dU-ncD"/>
|
||||
<constraint firstItem="PEP-UU-rG9" firstAttribute="top" secondItem="yxD-bn-7rJ" secondAttribute="bottom" constant="8" id="HaH-jK-jid"/>
|
||||
<constraint firstItem="euU-Ij-5LS" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="OHK-XM-nZh"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="euU-Ij-5LS" secondAttribute="trailing" constant="20" id="ShD-8C-ek1"/>
|
||||
<constraint firstItem="Xxd-ws-vsN" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="yxD-bn-7rJ" secondAttribute="trailing" constant="8" id="U9x-ti-Tu2"/>
|
||||
<constraint firstItem="VwJ-Ji-WmN" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="VWZ-aZ-O9Q"/>
|
||||
<constraint firstItem="tZE-CV-RS5" firstAttribute="top" secondItem="euU-Ij-5LS" secondAttribute="bottom" constant="8" id="YKK-UR-dnH"/>
|
||||
<constraint firstItem="Xxd-ws-vsN" firstAttribute="top" secondItem="reY-47-9dn" secondAttribute="top" constant="8" id="ZyL-ai-qvZ"/>
|
||||
<constraint firstAttribute="trailing" secondItem="PEP-UU-rG9" secondAttribute="trailing" constant="20" id="eKG-67-YFm"/>
|
||||
<constraint firstItem="PEP-UU-rG9" firstAttribute="leading" secondItem="reY-47-9dn" secondAttribute="leading" constant="20" id="ebf-0Y-flG"/>
|
||||
<constraint firstItem="VwJ-Ji-WmN" firstAttribute="top" secondItem="reY-47-9dn" secondAttribute="top" constant="8" id="ecl-RK-Ffb"/>
|
||||
<constraint firstItem="Hb5-Na-kgr" firstAttribute="trailing" secondItem="Xxd-ws-vsN" secondAttribute="trailing" constant="20" id="kT7-Pv-6je"/>
|
||||
<constraint firstItem="yxD-bn-7rJ" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="sqt-wW-SeY"/>
|
||||
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="tZE-CV-RS5" secondAttribute="trailing" constant="20" id="xac-fb-QAh"/>
|
||||
<constraint firstItem="Xxd-ws-vsN" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="VwJ-Ji-WmN" secondAttribute="trailing" constant="8" id="xgp-8f-SqT"/>
|
||||
<constraint firstItem="PEP-UU-rG9" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Xxd-ws-vsN" secondAttribute="bottom" constant="8" id="yzA-bn-qZ1"/>
|
||||
<constraint firstItem="euU-Ij-5LS" firstAttribute="top" secondItem="PEP-UU-rG9" secondAttribute="bottom" constant="8" id="zq0-Jn-mdy"/>
|
||||
<constraint firstItem="tZE-CV-RS5" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="zs5-jN-xgs"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="3292" y="-1399"/>
|
||||
</view>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
</objects>
|
||||
<resources>
|
||||
<namedColor name="primaryAccentColor">
|
||||
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<systemColor name="separatorColor">
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
40
iOS/MainWindow/Article/FindInArticleActivity.swift
Normal file
40
iOS/MainWindow/Article/FindInArticleActivity.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// FindInArticleActivity.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Brian Sanders on 5/7/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class FindInArticleActivity: UIActivity {
|
||||
override var activityTitle: String? {
|
||||
NSLocalizedString("Find in Article", comment: "Find in Article")
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||
}
|
||||
|
||||
override static var activityCategory: UIActivity.Category {
|
||||
.action
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
NotificationCenter.default.post(Notification(name: .FindInArticle))
|
||||
activityDidFinish(true)
|
||||
}
|
||||
}
|
||||
358
iOS/MainWindow/Article/ImageScrollView.swift
Normal file
358
iOS/MainWindow/Article/ImageScrollView.swift
Normal file
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// ImageScrollView.swift
|
||||
// Beauty
|
||||
//
|
||||
// Created by Nguyen Cong Huy on 1/19/16.
|
||||
// Copyright © 2016 Nguyen Cong Huy. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
|
||||
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
|
||||
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
|
||||
}
|
||||
|
||||
open class ImageScrollView: UIScrollView {
|
||||
|
||||
@objc public enum ScaleMode: Int {
|
||||
case aspectFill
|
||||
case aspectFit
|
||||
case widthFill
|
||||
case heightFill
|
||||
}
|
||||
|
||||
@objc public enum Offset: Int {
|
||||
case beginning
|
||||
case center
|
||||
}
|
||||
|
||||
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
|
||||
|
||||
@objc open var imageContentMode: ScaleMode = .widthFill
|
||||
@objc open var initialOffset: Offset = .beginning
|
||||
|
||||
@objc public private(set) var zoomView: UIImageView?
|
||||
|
||||
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
|
||||
|
||||
var imageSize: CGSize = CGSize.zero
|
||||
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
|
||||
private var scaleToRestoreAfterResize: CGFloat = 1.0
|
||||
var maxScaleFromMinScale: CGFloat = 3.0
|
||||
|
||||
var zoomedFrame: CGRect {
|
||||
return zoomView?.frame ?? CGRect.zero
|
||||
}
|
||||
|
||||
override open var frame: CGRect {
|
||||
willSet {
|
||||
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||
prepareToResize()
|
||||
}
|
||||
}
|
||||
|
||||
didSet {
|
||||
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||
recoverFromResizing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
showsVerticalScrollIndicator = false
|
||||
showsHorizontalScrollIndicator = false
|
||||
bouncesZoom = true
|
||||
decelerationRate = UIScrollView.DecelerationRate.fast
|
||||
delegate = self
|
||||
}
|
||||
|
||||
@objc public func adjustFrameToCenter() {
|
||||
|
||||
guard let unwrappedZoomView = zoomView else {
|
||||
return
|
||||
}
|
||||
|
||||
var frameToCenter = unwrappedZoomView.frame
|
||||
|
||||
// center horizontally
|
||||
if frameToCenter.size.width < bounds.width {
|
||||
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
|
||||
} else {
|
||||
frameToCenter.origin.x = 0
|
||||
}
|
||||
|
||||
// center vertically
|
||||
if frameToCenter.size.height < bounds.height {
|
||||
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
|
||||
} else {
|
||||
frameToCenter.origin.y = 0
|
||||
}
|
||||
|
||||
unwrappedZoomView.frame = frameToCenter
|
||||
}
|
||||
|
||||
private func prepareToResize() {
|
||||
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
|
||||
|
||||
scaleToRestoreAfterResize = zoomScale
|
||||
|
||||
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
|
||||
// allowable scale when the scale is restored.
|
||||
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
|
||||
scaleToRestoreAfterResize = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func recoverFromResizing() {
|
||||
setMaxMinZoomScalesForCurrentBounds()
|
||||
|
||||
// restore zoom scale, first making sure it is within the allowable range.
|
||||
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
|
||||
zoomScale = min(maximumZoomScale, maxZoomScale)
|
||||
|
||||
// restore center point, first making sure it is within the allowable range.
|
||||
|
||||
// convert our desired center point back to our own coordinate space
|
||||
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
|
||||
|
||||
// calculate the content offset that would yield that center point
|
||||
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
|
||||
|
||||
// restore offset, adjusted to be within the allowable range
|
||||
let maxOffset = maximumContentOffset()
|
||||
let minOffset = minimumContentOffset()
|
||||
|
||||
var realMaxOffset = min(maxOffset.x, offset.x)
|
||||
offset.x = max(minOffset.x, realMaxOffset)
|
||||
|
||||
realMaxOffset = min(maxOffset.y, offset.y)
|
||||
offset.y = max(minOffset.y, realMaxOffset)
|
||||
|
||||
contentOffset = offset
|
||||
}
|
||||
|
||||
private func maximumContentOffset() -> CGPoint {
|
||||
return CGPoint(x: contentSize.width - bounds.width, y: contentSize.height - bounds.height)
|
||||
}
|
||||
|
||||
private func minimumContentOffset() -> CGPoint {
|
||||
return CGPoint.zero
|
||||
}
|
||||
|
||||
// MARK: - Set up
|
||||
|
||||
open func setup() {
|
||||
var topSupperView = superview
|
||||
|
||||
while topSupperView?.superview != nil {
|
||||
topSupperView = topSupperView?.superview
|
||||
}
|
||||
|
||||
// Make sure views have already layout with precise frame
|
||||
topSupperView?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Display image
|
||||
|
||||
@objc open func display(image: UIImage) {
|
||||
|
||||
if let zoomView = zoomView {
|
||||
zoomView.removeFromSuperview()
|
||||
}
|
||||
|
||||
zoomView = UIImageView(image: image)
|
||||
zoomView!.isUserInteractionEnabled = true
|
||||
addSubview(zoomView!)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
|
||||
tapGesture.numberOfTapsRequired = 2
|
||||
zoomView!.addGestureRecognizer(tapGesture)
|
||||
|
||||
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
|
||||
downSwipeGesture.direction = .down
|
||||
zoomView!.addGestureRecognizer(downSwipeGesture)
|
||||
|
||||
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
|
||||
upSwipeGesture.direction = .up
|
||||
zoomView!.addGestureRecognizer(upSwipeGesture)
|
||||
|
||||
configureImageForSize(image.size)
|
||||
adjustFrameToCenter()
|
||||
}
|
||||
|
||||
private func configureImageForSize(_ size: CGSize) {
|
||||
imageSize = size
|
||||
contentSize = imageSize
|
||||
setMaxMinZoomScalesForCurrentBounds()
|
||||
zoomScale = minimumZoomScale
|
||||
|
||||
switch initialOffset {
|
||||
case .beginning:
|
||||
contentOffset = CGPoint.zero
|
||||
case .center:
|
||||
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
|
||||
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
|
||||
|
||||
switch imageContentMode {
|
||||
case .aspectFit:
|
||||
contentOffset = CGPoint.zero
|
||||
case .aspectFill:
|
||||
contentOffset = CGPoint(x: xOffset, y: yOffset)
|
||||
case .heightFill:
|
||||
contentOffset = CGPoint(x: xOffset, y: 0)
|
||||
case .widthFill:
|
||||
contentOffset = CGPoint(x: 0, y: yOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setMaxMinZoomScalesForCurrentBounds() {
|
||||
// calculate min/max zoomscale
|
||||
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
|
||||
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
|
||||
|
||||
var minScale: CGFloat = 1
|
||||
|
||||
switch imageContentMode {
|
||||
case .aspectFill:
|
||||
minScale = max(xScale, yScale)
|
||||
case .aspectFit:
|
||||
minScale = min(xScale, yScale)
|
||||
case .widthFill:
|
||||
minScale = xScale
|
||||
case .heightFill:
|
||||
minScale = yScale
|
||||
}
|
||||
|
||||
let maxScale = maxScaleFromMinScale*minScale
|
||||
|
||||
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
|
||||
if minScale > maxScale {
|
||||
minScale = maxScale
|
||||
}
|
||||
|
||||
maximumZoomScale = maxScale
|
||||
minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
|
||||
}
|
||||
|
||||
// MARK: - Gesture
|
||||
|
||||
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
// zoom out if it bigger than middle scale point. Else, zoom in
|
||||
if zoomScale >= maximumZoomScale / 2.0 {
|
||||
setZoomScale(minimumZoomScale, animated: true)
|
||||
} else {
|
||||
let center = gestureRecognizer.location(in: gestureRecognizer.view)
|
||||
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
|
||||
zoom(to: zoomRect, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
if gestureRecognizer.state == .ended {
|
||||
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
if gestureRecognizer.state == .ended {
|
||||
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
|
||||
var zoomRect = CGRect.zero
|
||||
|
||||
// the zoom rect is in the content view's coordinates.
|
||||
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
|
||||
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
|
||||
zoomRect.size.height = frame.size.height / scale
|
||||
zoomRect.size.width = frame.size.width / scale
|
||||
|
||||
// choose an origin so as to get the right center.
|
||||
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
|
||||
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
|
||||
|
||||
return zoomRect
|
||||
}
|
||||
|
||||
open func refresh() {
|
||||
if let image = zoomView?.image {
|
||||
display(image: image)
|
||||
}
|
||||
}
|
||||
|
||||
open func resize() {
|
||||
self.configureImageForSize(self.imageSize)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageScrollView: UIScrollViewDelegate {
|
||||
|
||||
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
|
||||
}
|
||||
|
||||
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
|
||||
}
|
||||
|
||||
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return zoomView
|
||||
}
|
||||
|
||||
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
adjustFrameToCenter()
|
||||
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
|
||||
}
|
||||
}
|
||||
110
iOS/MainWindow/Article/ImageTransition.swift
Normal file
110
iOS/MainWindow/Article/ImageTransition.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ImageAnimator.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 10/15/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
|
||||
private weak var webViewController: WebViewController?
|
||||
private let duration = 0.4
|
||||
var presenting = true
|
||||
var originFrame: CGRect!
|
||||
var maskFrame: CGRect!
|
||||
var originImage: UIImage!
|
||||
|
||||
init(controller: WebViewController) {
|
||||
self.webViewController = controller
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return duration
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
if presenting {
|
||||
animateTransitionPresenting(using: transitionContext)
|
||||
} else {
|
||||
animateTransitionReturning(using: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
||||
let imageView = UIImageView(image: originImage)
|
||||
imageView.frame = originFrame
|
||||
|
||||
let fromView = transitionContext.view(forKey: .from)!
|
||||
fromView.removeFromSuperview()
|
||||
|
||||
transitionContext.containerView.backgroundColor = AppColor.fullScreenBackground
|
||||
transitionContext.containerView.addSubview(imageView)
|
||||
|
||||
webViewController?.hideClickedImage()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0.0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.2,
|
||||
animations: {
|
||||
let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController
|
||||
imageView.frame = imageController.zoomedFrame
|
||||
}, completion: { _ in
|
||||
imageView.removeFromSuperview()
|
||||
let toView = transitionContext.view(forKey: .to)!
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
transitionContext.completeTransition(true)
|
||||
})
|
||||
}
|
||||
|
||||
private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController
|
||||
let imageView = UIImageView(image: originImage)
|
||||
imageView.frame = imageController.zoomedFrame
|
||||
|
||||
let fromView = transitionContext.view(forKey: .from)!
|
||||
let windowFrame = fromView.window!.frame
|
||||
fromView.removeFromSuperview()
|
||||
|
||||
let toView = transitionContext.view(forKey: .to)!
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
|
||||
let maskingView = UIView()
|
||||
|
||||
let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height)
|
||||
let path = UIBezierPath(rect: fullMaskFrame)
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.path = path.cgPath
|
||||
maskingView.layer.mask = maskLayer
|
||||
|
||||
maskingView.addSubview(imageView)
|
||||
transitionContext.containerView.addSubview(maskingView)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0.0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.2,
|
||||
animations: {
|
||||
imageView.frame = self.originFrame
|
||||
}, completion: { _ in
|
||||
if let controller = self.webViewController {
|
||||
controller.showClickedImage {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
imageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
98
iOS/MainWindow/Article/ImageViewController.swift
Normal file
98
iOS/MainWindow/Article/ImageViewController.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// ImageViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 10/12/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ImageViewController: UIViewController {
|
||||
|
||||
@IBOutlet weak var closeButton: UIButton!
|
||||
@IBOutlet weak var shareButton: UIButton!
|
||||
@IBOutlet weak var imageScrollView: ImageScrollView!
|
||||
@IBOutlet weak var titleLabel: UILabel!
|
||||
@IBOutlet weak var titleBackground: UIVisualEffectView!
|
||||
@IBOutlet weak var titleLeading: NSLayoutConstraint!
|
||||
@IBOutlet weak var titleTrailing: NSLayoutConstraint!
|
||||
|
||||
var image: UIImage!
|
||||
var imageTitle: String?
|
||||
var zoomedFrame: CGRect {
|
||||
return imageScrollView.zoomedFrame
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(nibName: "ImageViewController", bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
closeButton.imageView?.contentMode = .scaleAspectFit
|
||||
closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close")
|
||||
shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share")
|
||||
|
||||
imageScrollView.setup()
|
||||
imageScrollView.imageScrollViewDelegate = self
|
||||
imageScrollView.imageContentMode = .aspectFit
|
||||
imageScrollView.initialOffset = .center
|
||||
imageScrollView.display(image: image)
|
||||
|
||||
titleLabel.text = imageTitle ?? ""
|
||||
layoutTitleLabel()
|
||||
|
||||
guard imageTitle != "" else {
|
||||
titleBackground.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
titleBackground.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
coordinator.animate(alongsideTransition: { [weak self] _ in
|
||||
self?.imageScrollView.resize()
|
||||
})
|
||||
}
|
||||
|
||||
@IBAction func share(_ sender: Any) {
|
||||
guard let image = image else { return }
|
||||
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
activityViewController.popoverPresentationController?.sourceView = shareButton
|
||||
activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds
|
||||
present(activityViewController, animated: true)
|
||||
}
|
||||
|
||||
@IBAction func done(_ sender: Any) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func layoutTitleLabel() {
|
||||
let width = view.frame.width
|
||||
let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04)
|
||||
titleLeading.constant += width * multiplier
|
||||
titleTrailing.constant -= width * multiplier
|
||||
titleLabel.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ImageScrollViewDelegate
|
||||
|
||||
extension ImageViewController: ImageScrollViewDelegate {
|
||||
|
||||
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
107
iOS/MainWindow/Article/ImageViewController.xib
Normal file
107
iOS/MainWindow/Article/ImageViewController.xib
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ImageViewController" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="closeButton" destination="bh5-KV-5HI" id="3gh-Y3-Kjf"/>
|
||||
<outlet property="imageScrollView" destination="t42-v5-7DN" id="TR2-aK-Pz0"/>
|
||||
<outlet property="shareButton" destination="3Wa-fp-kMe" id="jjN-jQ-BWP"/>
|
||||
<outlet property="titleBackground" destination="rXU-KY-jBH" id="JGd-Fp-biL"/>
|
||||
<outlet property="titleLabel" destination="lQ6-x9-Tcu" id="QPn-ac-kYi"/>
|
||||
<outlet property="titleLeading" destination="bHE-Eq-ddT" id="WKB-C3-z0s"/>
|
||||
<outlet property="titleTrailing" destination="OFG-cU-iTN" id="VyL-dz-6Ch"/>
|
||||
<outlet property="view" destination="2qJ-Gw-Tlk" id="S0T-fW-KSq"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="2qJ-Gw-Tlk">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<scrollView verifyAmbiguity="off" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="t42-v5-7DN" customClass="ImageScrollView" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<viewLayoutGuide key="contentLayoutGuide" id="rDi-IL-3hP"/>
|
||||
<viewLayoutGuide key="frameLayoutGuide" id="Rk2-H7-hcc"/>
|
||||
</scrollView>
|
||||
<visualEffectView opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rXU-KY-jBH">
|
||||
<rect key="frame" x="-4" y="806" width="401" height="8"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Sbe-dT-bwF">
|
||||
<rect key="frame" x="0.0" y="0.0" width="401" height="8"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<blurEffect style="systemUltraThinMaterial"/>
|
||||
</visualEffectView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lQ6-x9-Tcu">
|
||||
<rect key="frame" x="0.0" y="810" width="393" height="0.0"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="3Wa-fp-kMe">
|
||||
<rect key="frame" x="341" y="59" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="BG7-ht-naS"/>
|
||||
<constraint firstAttribute="height" constant="44" id="cuX-WF-pUh"/>
|
||||
</constraints>
|
||||
<color key="tintColor" name="primaryAccentColor"/>
|
||||
<state key="normal" image="square.and.arrow.up.fill" catalog="system"/>
|
||||
<connections>
|
||||
<action selector="share:" destination="-1" eventType="touchUpInside" id="jti-xh-2Yt"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" clipsSubviews="YES" contentMode="center" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bh5-KV-5HI">
|
||||
<rect key="frame" x="8" y="59" width="44" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="44" id="VXi-hw-q5q"/>
|
||||
<constraint firstAttribute="height" constant="44" id="yWs-vd-PHK"/>
|
||||
</constraints>
|
||||
<color key="tintColor" name="primaryAccentColor"/>
|
||||
<state key="normal" image="multiply.circle.fill" catalog="system">
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large" weight="regular"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="done:" destination="-1" eventType="touchUpInside" id="OZB-pn-m1N"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Mwx-oc-6Cf"/>
|
||||
<color key="backgroundColor" name="fullScreenBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="3Wa-fp-kMe" firstAttribute="top" secondItem="Mwx-oc-6Cf" secondAttribute="top" id="3Mg-5D-jao"/>
|
||||
<constraint firstItem="rXU-KY-jBH" firstAttribute="bottom" secondItem="lQ6-x9-Tcu" secondAttribute="bottom" constant="4" id="5AX-Pq-B03"/>
|
||||
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Mwx-oc-6Cf" secondAttribute="trailing" id="OFG-cU-iTN"/>
|
||||
<constraint firstItem="rXU-KY-jBH" firstAttribute="trailing" secondItem="lQ6-x9-Tcu" secondAttribute="trailing" constant="4" id="P5Y-FS-He4"/>
|
||||
<constraint firstItem="rXU-KY-jBH" firstAttribute="leading" secondItem="lQ6-x9-Tcu" secondAttribute="leading" constant="-4" id="PeO-2J-lve"/>
|
||||
<constraint firstItem="rXU-KY-jBH" firstAttribute="top" secondItem="lQ6-x9-Tcu" secondAttribute="top" constant="-4" id="Rgg-Bx-6R4"/>
|
||||
<constraint firstItem="Mwx-oc-6Cf" firstAttribute="bottom" secondItem="lQ6-x9-Tcu" secondAttribute="bottom" constant="8" id="Thj-eJ-Fvw"/>
|
||||
<constraint firstItem="bh5-KV-5HI" firstAttribute="top" secondItem="Mwx-oc-6Cf" secondAttribute="top" id="WIh-RA-e0M"/>
|
||||
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Mwx-oc-6Cf" secondAttribute="leading" id="bHE-Eq-ddT"/>
|
||||
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="centerX" secondItem="2qJ-Gw-Tlk" secondAttribute="centerX" id="dhZ-1K-ezQ"/>
|
||||
<constraint firstItem="t42-v5-7DN" firstAttribute="leading" secondItem="2qJ-Gw-Tlk" secondAttribute="leading" id="eFN-9k-B2Z"/>
|
||||
<constraint firstItem="Mwx-oc-6Cf" firstAttribute="trailing" secondItem="3Wa-fp-kMe" secondAttribute="trailing" constant="8" id="jLl-sH-LgH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="t42-v5-7DN" secondAttribute="bottom" id="ovl-eX-nHk"/>
|
||||
<constraint firstItem="t42-v5-7DN" firstAttribute="top" secondItem="2qJ-Gw-Tlk" secondAttribute="top" id="xAA-lL-BRQ"/>
|
||||
<constraint firstAttribute="trailing" secondItem="t42-v5-7DN" secondAttribute="trailing" id="xh0-GX-R9G"/>
|
||||
<constraint firstItem="bh5-KV-5HI" firstAttribute="leading" secondItem="Mwx-oc-6Cf" secondAttribute="leading" constant="8" id="yP2-xR-h9i"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="-460" y="-627"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="multiply.circle.fill" catalog="system" width="128" height="123"/>
|
||||
<image name="square.and.arrow.up.fill" catalog="system" width="117" height="128"/>
|
||||
<namedColor name="fullScreenBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</namedColor>
|
||||
<namedColor name="primaryAccentColor">
|
||||
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
49
iOS/MainWindow/Article/OpenInSafariActivity.swift
Normal file
49
iOS/MainWindow/Article/OpenInSafariActivity.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// OpenInBrowserActivity.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 1/9/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class OpenInBrowserActivity: UIActivity {
|
||||
|
||||
private var activityItems: [Any]?
|
||||
|
||||
override var activityTitle: String? {
|
||||
return NSLocalizedString("Open in Browser", comment: "Open in Browser")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return UIActivity.ActivityType(rawValue: "com.rancharo.NetNewsWire-Evergreen.safari")
|
||||
}
|
||||
|
||||
override static var activityCategory: UIActivity.Category {
|
||||
return .action
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
self.activityItems = activityItems
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
guard let url = activityItems?.first(where: { $0 is URL }) as? URL else {
|
||||
activityDidFinish(false)
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
activityDidFinish(true)
|
||||
}
|
||||
|
||||
}
|
||||
862
iOS/MainWindow/Article/WebViewController.swift
Normal file
862
iOS/MainWindow/Article/WebViewController.swift
Normal file
@@ -0,0 +1,862 @@
|
||||
//
|
||||
// WebViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 12/28/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
@preconcurrency import WebKit
|
||||
import RSCore
|
||||
import Account
|
||||
import Articles
|
||||
import SafariServices
|
||||
import MessageUI
|
||||
|
||||
protocol WebViewControllerDelegate: AnyObject {
|
||||
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
|
||||
}
|
||||
|
||||
final class WebViewController: UIViewController {
|
||||
|
||||
private struct MessageName {
|
||||
static let imageWasClicked = "imageWasClicked"
|
||||
static let imageWasShown = "imageWasShown"
|
||||
static let showFeedInspector = "showFeedInspector"
|
||||
}
|
||||
|
||||
private var topShowBarsView: UIView!
|
||||
private var bottomShowBarsView: UIView!
|
||||
private var topShowBarsViewConstraint: NSLayoutConstraint!
|
||||
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
|
||||
|
||||
var webView: WKWebView? {
|
||||
return view.subviews[0] as? WKWebView
|
||||
}
|
||||
|
||||
private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
private var isFullScreenAvailable: Bool {
|
||||
return AppDefaults.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed
|
||||
}
|
||||
private lazy var articleIconSchemeHandler = ArticleIconSchemeHandler(delegate: self)
|
||||
private lazy var transition = ImageTransition(controller: self)
|
||||
private var clickedImageCompletion: (() -> Void)?
|
||||
|
||||
private var articleExtractor: ArticleExtractor?
|
||||
var extractedArticle: ExtractedArticle? {
|
||||
didSet {
|
||||
windowScrollY = 0
|
||||
}
|
||||
}
|
||||
var isShowingExtractedArticle = false
|
||||
|
||||
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
|
||||
didSet {
|
||||
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
|
||||
}
|
||||
}
|
||||
|
||||
weak var coordinator: SceneCoordinator!
|
||||
weak var delegate: WebViewControllerDelegate?
|
||||
|
||||
private(set) var article: Article?
|
||||
|
||||
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
|
||||
var windowScrollY = 0
|
||||
private var restoreWindowScrollY: Int?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, 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(currentArticleThemeDidChangeNotification(_:)), name: .CurrentArticleThemeDidChangeNotification, object: nil)
|
||||
|
||||
// Configure the tap zones
|
||||
configureTopShowBarsView()
|
||||
configureBottomShowBarsView()
|
||||
|
||||
loadWebView()
|
||||
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc func feedIconDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
@objc func avatarDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
@objc func currentArticleThemeDidChangeNotification(_ note: Notification) {
|
||||
loadWebView()
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc func showBars(_ sender: Any) {
|
||||
showBars()
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func setArticle(_ article: Article?, updateView: Bool = true) {
|
||||
stopArticleExtractor()
|
||||
|
||||
if article != self.article {
|
||||
self.article = article
|
||||
if updateView {
|
||||
if article?.feed?.isArticleExtractorAlwaysOn ?? false {
|
||||
startArticleExtractor()
|
||||
}
|
||||
windowScrollY = 0
|
||||
loadWebView()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func setScrollPosition(isShowingExtractedArticle: Bool, articleWindowScrollY: Int) {
|
||||
if isShowingExtractedArticle {
|
||||
switch articleExtractor?.state {
|
||||
case .ready:
|
||||
restoreWindowScrollY = articleWindowScrollY
|
||||
startArticleExtractor()
|
||||
case .complete:
|
||||
windowScrollY = articleWindowScrollY
|
||||
loadWebView()
|
||||
case .processing:
|
||||
restoreWindowScrollY = articleWindowScrollY
|
||||
default:
|
||||
restoreWindowScrollY = articleWindowScrollY
|
||||
startArticleExtractor()
|
||||
}
|
||||
} else {
|
||||
windowScrollY = articleWindowScrollY
|
||||
loadWebView()
|
||||
}
|
||||
}
|
||||
|
||||
func focus() {
|
||||
webView?.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func canScrollDown() -> Bool {
|
||||
guard let webView = webView else { return false }
|
||||
return webView.scrollView.contentOffset.y < finalScrollPosition(scrollingUp: false)
|
||||
}
|
||||
|
||||
func canScrollUp() -> Bool {
|
||||
guard let webView = webView else { return false }
|
||||
return webView.scrollView.contentOffset.y > finalScrollPosition(scrollingUp: true)
|
||||
}
|
||||
|
||||
private func scrollPage(up scrollingUp: Bool) {
|
||||
guard let webView = webView else { return }
|
||||
|
||||
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
|
||||
let scrollToY: CGFloat = {
|
||||
let scrollDistance = webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
|
||||
let fullScroll = webView.scrollView.contentOffset.y + (scrollingUp ? -scrollDistance : scrollDistance)
|
||||
let final = finalScrollPosition(scrollingUp: scrollingUp)
|
||||
return (scrollingUp ? fullScroll > final : 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 scrollPageDown() {
|
||||
scrollPage(up: false)
|
||||
}
|
||||
|
||||
func scrollPageUp() {
|
||||
scrollPage(up: true)
|
||||
}
|
||||
|
||||
func hideClickedImage() {
|
||||
webView?.evaluateJavaScript("hideClickedImage();")
|
||||
}
|
||||
|
||||
func showClickedImage(completion: @escaping () -> Void) {
|
||||
clickedImageCompletion = completion
|
||||
webView?.evaluateJavaScript("showClickedImage();")
|
||||
}
|
||||
|
||||
func fullReload() {
|
||||
loadWebView(replaceExistingWebView: true)
|
||||
}
|
||||
|
||||
func showBars() {
|
||||
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()
|
||||
loadWebView()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isShowingExtractedArticle else {
|
||||
isShowingExtractedArticle = false
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .off
|
||||
return
|
||||
}
|
||||
|
||||
if let articleExtractor = articleExtractor {
|
||||
if article.preferredLink == articleExtractor.articleLink {
|
||||
isShowingExtractedArticle = true
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .on
|
||||
}
|
||||
} else {
|
||||
startArticleExtractor()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func stopArticleExtractorIfProcessing() {
|
||||
if articleExtractor?.state == .processing {
|
||||
stopArticleExtractor()
|
||||
}
|
||||
}
|
||||
|
||||
func stopWebViewActivity() {
|
||||
if let webView = webView {
|
||||
stopMediaPlayback(webView)
|
||||
cancelImageLoad(webView)
|
||||
}
|
||||
}
|
||||
|
||||
func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) {
|
||||
guard let url = article?.preferredURL else { return }
|
||||
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [FindInArticleActivity(), OpenInBrowserActivity()])
|
||||
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
|
||||
present(activityViewController, animated: true)
|
||||
}
|
||||
|
||||
func openInAppBrowser() {
|
||||
guard let url = article?.preferredURL else { return }
|
||||
if AppDefaults.useSystemBrowser {
|
||||
UIApplication.shared.open(url, options: [:])
|
||||
} else {
|
||||
openURLInSafariViewController(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ArticleIconSchemeHandlerDelegate
|
||||
|
||||
extension WebViewController: ArticleIconSchemeHandlerDelegate {
|
||||
|
||||
func articleIconSchemeHandler(_: ArticleIconSchemeHandler, imageForArticleID articleID: String) -> IconImage? {
|
||||
|
||||
guard let article else {
|
||||
assertionFailure("Did not expect request for article image when there is no current article.")
|
||||
return nil
|
||||
}
|
||||
guard articleID == article.articleID else {
|
||||
assertionFailure("Expected articleID to match current articleID.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return article.iconImage() // May be nil — not a programming error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ArticleExtractorDelegate
|
||||
|
||||
extension WebViewController: ArticleExtractorDelegate {
|
||||
|
||||
func articleExtractionDidFail(with: Error) {
|
||||
stopArticleExtractor()
|
||||
articleExtractorButtonState = .error
|
||||
loadWebView()
|
||||
}
|
||||
|
||||
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
|
||||
if articleExtractor?.state != .cancelled {
|
||||
self.extractedArticle = extractedArticle
|
||||
if let restoreWindowScrollY = restoreWindowScrollY {
|
||||
windowScrollY = restoreWindowScrollY
|
||||
}
|
||||
isShowingExtractedArticle = true
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .on
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIContextMenuInteractionDelegate
|
||||
|
||||
extension WebViewController: UIContextMenuInteractionDelegate {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] _ in
|
||||
guard let self = self else { return nil }
|
||||
|
||||
var menus = [UIMenu]()
|
||||
|
||||
var navActions = [UIAction]()
|
||||
if let action = self.prevArticleAction() {
|
||||
navActions.append(action)
|
||||
}
|
||||
if let action = self.nextArticleAction() {
|
||||
navActions.append(action)
|
||||
}
|
||||
if !navActions.isEmpty {
|
||||
menus.append(UIMenu(title: "", options: .displayInline, children: navActions))
|
||||
}
|
||||
|
||||
var toggleActions = [UIAction]()
|
||||
if let action = self.toggleReadAction() {
|
||||
toggleActions.append(action)
|
||||
}
|
||||
toggleActions.append(self.toggleStarredAction())
|
||||
menus.append(UIMenu(title: "", options: .displayInline, children: toggleActions))
|
||||
|
||||
if let action = self.nextUnreadArticleAction() {
|
||||
menus.append(UIMenu(title: "", options: .displayInline, children: [action]))
|
||||
}
|
||||
|
||||
menus.append(UIMenu(title: "", options: .displayInline, children: [self.toggleArticleExtractorAction()]))
|
||||
menus.append(UIMenu(title: "", options: .displayInline, children: [self.shareAction()]))
|
||||
|
||||
return UIMenu(title: "", children: menus)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
decisionHandler(.cancel)
|
||||
if AppDefaults.useSystemBrowser {
|
||||
UIApplication.shared.open(url, options: [:])
|
||||
} else {
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
|
||||
guard didOpen == false else {
|
||||
return
|
||||
}
|
||||
self.openURLInSafariViewController(url)
|
||||
}
|
||||
}
|
||||
|
||||
} else if components?.scheme == "mailto" {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
guard let emailAddress = url.percentEncodedEmailAddress else {
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(emailAddress) {
|
||||
UIApplication.shared.open(emailAddress, options: [.universalLinksOnly: false], completionHandler: nil)
|
||||
} else {
|
||||
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
|
||||
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
} else if components?.scheme == "tel" {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly: false], completionHandler: nil)
|
||||
}
|
||||
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
fullReload()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 should 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. ¯\_(ツ)_/¯
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
guard let url = navigationAction.request.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
openURL(url)
|
||||
return 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)
|
||||
case MessageName.showFeedInspector:
|
||||
if let feed = article?.feed {
|
||||
coordinator.showFeedInspector(for: feed)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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:
|
||||
|
||||
extension WebViewController: UIScrollViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
|
||||
}
|
||||
|
||||
@objc func scrollPositionDidChange() {
|
||||
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
|
||||
guard error == nil else { return }
|
||||
let javascriptScrollY = scrollY as? Int ?? 0
|
||||
// I don't know why this value gets returned sometimes, but it is in error
|
||||
guard javascriptScrollY != 33554432 else { return }
|
||||
self.windowScrollY = javascriptScrollY
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
private struct ImageClickMessage: Codable {
|
||||
let x: Float
|
||||
let y: Float
|
||||
let width: Float
|
||||
let height: Float
|
||||
let imageTitle: String?
|
||||
let imageURL: String
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension WebViewController {
|
||||
|
||||
func loadWebView(replaceExistingWebView: Bool = false) {
|
||||
guard isViewLoaded else { return }
|
||||
|
||||
if !replaceExistingWebView, let webView {
|
||||
self.renderPage(webView)
|
||||
return
|
||||
}
|
||||
|
||||
let configuration = WebViewConfiguration.configuration(with: articleIconSchemeHandler)
|
||||
|
||||
let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
|
||||
// Add the webview - using autolayout will cause fullscreen video to fail and lose the web view
|
||||
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.view.insertSubview(webView, at: 0)
|
||||
|
||||
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
|
||||
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
|
||||
// us from easily swiping between WKWebViews. This hack fixes that.
|
||||
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
|
||||
|
||||
webView.scrollView.setZoomScale(1.0, animated: false)
|
||||
|
||||
self.view.setNeedsLayout()
|
||||
|
||||
// Configure the webview
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
webView.scrollView.delegate = self
|
||||
self.configureContextMenuInteraction()
|
||||
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
|
||||
|
||||
self.renderPage(webView)
|
||||
}
|
||||
|
||||
func renderPage(_ webView: WKWebView?) {
|
||||
guard let webView = webView else { return }
|
||||
|
||||
let theme = ArticleThemesManager.shared.currentTheme
|
||||
let rendering: ArticleRenderer.Rendering
|
||||
|
||||
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
|
||||
rendering = ArticleRenderer.loadingHTML(theme: theme)
|
||||
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
} else if let article = article, let extractedArticle = extractedArticle {
|
||||
if isShowingExtractedArticle {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
|
||||
} else {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
}
|
||||
} else if let article = article {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
} else {
|
||||
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
|
||||
}
|
||||
|
||||
let substitutions = [
|
||||
"title": rendering.title,
|
||||
"baseURL": rendering.baseURL,
|
||||
"style": rendering.style,
|
||||
"body": rendering.html,
|
||||
"windowScrollY": String(windowScrollY)
|
||||
]
|
||||
|
||||
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
|
||||
webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL))
|
||||
}
|
||||
|
||||
func finalScrollPosition(scrollingUp: Bool) -> CGFloat {
|
||||
guard let webView = webView else { return 0 }
|
||||
|
||||
if scrollingUp {
|
||||
return -webView.scrollView.safeAreaInsets.top
|
||||
} else {
|
||||
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
func startArticleExtractor() {
|
||||
guard articleExtractor == nil else { return }
|
||||
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() {
|
||||
guard let article = article else { return }
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = ArticleRenderer.imageIconScheme
|
||||
components.path = article.articleID
|
||||
|
||||
if let imageSrc = components.string {
|
||||
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
|
||||
}
|
||||
}
|
||||
|
||||
func imageWasClicked(body: String?) {
|
||||
guard let webView = webView,
|
||||
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, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMediaPlayback(_ webView: WKWebView) {
|
||||
webView.evaluateJavaScript("stopMediaPlayback();")
|
||||
}
|
||||
|
||||
func cancelImageLoad(_ webView: WKWebView) {
|
||||
webView.evaluateJavaScript("cancelImageLoad();")
|
||||
}
|
||||
|
||||
func configureTopShowBarsView() {
|
||||
topShowBarsView = UIView()
|
||||
topShowBarsView.backgroundColor = .clear
|
||||
topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(topShowBarsView)
|
||||
|
||||
if AppDefaults.logicalArticleFullscreenEnabled {
|
||||
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.logicalArticleFullscreenEnabled {
|
||||
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 {
|
||||
ContextMenuPreviewViewController(article: article)
|
||||
}
|
||||
|
||||
func prevArticleAction() -> UIAction? {
|
||||
guard coordinator.isPrevArticleAvailable else { return nil }
|
||||
let title = NSLocalizedString("Previous Article", comment: "Previous Article")
|
||||
return UIAction(title: title, image: AppImage.previousArticle) { [weak self] _ 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: AppImage.nextArticle) { [weak self] _ in
|
||||
self?.coordinator.selectNextArticle()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleReadAction() -> UIAction? {
|
||||
guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
|
||||
|
||||
let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
|
||||
let readImage = article.status.read ? AppImage.circleClosed : AppImage.circleOpen
|
||||
return UIAction(title: title, image: readImage) { [weak self] _ 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 ? AppImage.starOpen : AppImage.starClosed
|
||||
return UIAction(title: title, image: starredImage) { [weak self] _ 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: AppImage.nextUnreadArticle) { [weak self] _ 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 ? AppImage.articleExtractorOffSF : AppImage.articleExtractorOnSF
|
||||
return UIAction(title: title, image: extractorImage) { [weak self] _ in
|
||||
self?.toggleArticleExtractor()
|
||||
}
|
||||
}
|
||||
|
||||
func shareAction() -> UIAction {
|
||||
let title = NSLocalizedString("Share", comment: "Share")
|
||||
return UIAction(title: title, image: AppImage.share) { [weak self] _ in
|
||||
self?.showActivityDialog()
|
||||
}
|
||||
}
|
||||
|
||||
// If the resource cannot be opened with an installed app, present the web view.
|
||||
func openURL(_ url: URL) {
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
|
||||
assert(Thread.isMainThread)
|
||||
guard didOpen == false else {
|
||||
return
|
||||
}
|
||||
self.openURLInSafariViewController(url)
|
||||
}
|
||||
}
|
||||
|
||||
func openURLInSafariViewController(_ url: URL) {
|
||||
|
||||
let viewController = SFSafariViewController(url: url)
|
||||
present(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Find in Article
|
||||
|
||||
private struct FindInArticleOptions: Codable {
|
||||
var text: String
|
||||
var caseSensitive = false
|
||||
var regex = false
|
||||
}
|
||||
|
||||
internal struct FindInArticleState: Codable {
|
||||
struct WebViewClientRect: Codable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
||||
|
||||
struct FindInArticleResult: Codable {
|
||||
let rects: [WebViewClientRect]
|
||||
let bounds: WebViewClientRect
|
||||
let index: UInt
|
||||
let matchGroups: [String]
|
||||
}
|
||||
|
||||
let index: UInt?
|
||||
let results: [FindInArticleResult]
|
||||
let count: UInt
|
||||
}
|
||||
|
||||
extension WebViewController {
|
||||
|
||||
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
|
||||
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
|
||||
return
|
||||
}
|
||||
let encoded = json.base64EncodedString()
|
||||
|
||||
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") { (result, error) in
|
||||
guard error == nil,
|
||||
let b64 = result as? String,
|
||||
let rawData = Data(base64Encoded: b64),
|
||||
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
|
||||
return
|
||||
}
|
||||
|
||||
completionHandler(findState)
|
||||
}
|
||||
}
|
||||
|
||||
func endSearch() {
|
||||
webView?.evaluateJavaScript("endFind()")
|
||||
}
|
||||
|
||||
func selectNextSearchResult() {
|
||||
webView?.evaluateJavaScript("selectNextResult()")
|
||||
}
|
||||
|
||||
func selectPreviousSearchResult() {
|
||||
webView?.evaluateJavaScript("selectPreviousResult()")
|
||||
}
|
||||
|
||||
}
|
||||
25
iOS/MainWindow/Article/WrapperScriptMessageHandler.swift
Normal file
25
iOS/MainWindow/Article/WrapperScriptMessageHandler.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// WrapperScriptMessageHandler.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 2/4/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final 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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user