Rename Master* to Main*.

This commit is contained in:
Brent Simmons
2024-12-24 12:09:41 -08:00
parent a86d4dbab4
commit b601c181dd
29 changed files with 385 additions and 453 deletions

View File

@@ -0,0 +1,90 @@
//
// MainTimelineAccessibilityCellLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 4/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct MainTimelineAccessibilityCellLayout: MainTimelineCellLayout {
let height: CGFloat
let unreadIndicatorRect: CGRect
let starRect: CGRect
let iconImageRect: CGRect
let titleRect: CGRect
let summaryRect: CGRect
let feedNameRect: CGRect
let dateRect: CGRect
init(width: CGFloat, insets: UIEdgeInsets, cellData: MainTimelineCellData) {
var currentPoint = CGPoint.zero
currentPoint.x = MainTimelineDefaultCellLayout.cellPadding.left + insets.left + MainTimelineDefaultCellLayout.unreadCircleMarginLeft
currentPoint.y = MainTimelineDefaultCellLayout.cellPadding.top
// Unread Indicator and Star
self.unreadIndicatorRect = MainTimelineAccessibilityCellLayout.rectForUnreadIndicator(currentPoint)
self.starRect = MainTimelineAccessibilityCellLayout.rectForStar(currentPoint)
// Start the point at the beginning position of the main block
currentPoint.x += MainTimelineDefaultCellLayout.unreadCircleDimension + MainTimelineDefaultCellLayout.unreadCircleMarginRight
// Icon Image
if cellData.showIcon {
self.iconImageRect = MainTimelineAccessibilityCellLayout.rectForIconView(currentPoint, iconSize: cellData.iconSize)
currentPoint.y = self.iconImageRect.maxY
} else {
self.iconImageRect = CGRect.zero
}
let textAreaWidth = width - (currentPoint.x + MainTimelineDefaultCellLayout.cellPadding.right + insets.right)
// Title Text Block
let (titleRect, numberOfLinesForTitle) = MainTimelineAccessibilityCellLayout.rectForTitle(cellData, currentPoint, textAreaWidth)
self.titleRect = titleRect
// Summary Text Block
if self.titleRect != CGRect.zero {
currentPoint.y = self.titleRect.maxY + MainTimelineDefaultCellLayout.titleBottomMargin
}
self.summaryRect = MainTimelineAccessibilityCellLayout.rectForSummary(cellData, currentPoint, textAreaWidth, numberOfLinesForTitle)
currentPoint.y = [self.titleRect, self.summaryRect].maxY()
if cellData.showFeedName != .none {
self.feedNameRect = MainTimelineAccessibilityCellLayout.rectForFeedName(cellData, currentPoint, textAreaWidth)
currentPoint.y = self.feedNameRect.maxY
} else {
self.feedNameRect = CGRect.zero
}
// Feed Name and Pub Date
self.dateRect = MainTimelineAccessibilityCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
self.height = self.dateRect.maxY + MainTimelineDefaultCellLayout.cellPadding.bottom
}
}
// MARK: - Calculate Rects
private extension MainTimelineAccessibilityCellLayout {
static func rectForDate(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
let size = SingleLineUILabelSizer.size(for: cellData.dateString, font: MainTimelineDefaultCellLayout.dateFont)
r.size = size
r.origin = point
return r
}
}

View File

@@ -0,0 +1,85 @@
//
// MainTimelineCellData.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import Articles
struct MainTimelineCellData {
private static let noText = NSLocalizedString("(No Text)", comment: "No Text")
let title: String
let attributedTitle: NSAttributedString
let summary: String
let dateString: String
let feedName: String
let byline: String
let showFeedName: ShowFeedName
let iconImage: IconImage? // feed icon, user avatar, or favicon
let showIcon: Bool // Make space even when icon is nil
let read: Bool
let starred: Bool
let numberOfLines: Int
let iconSize: IconSize
init(article: Article, showFeedName: ShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, numberOfLines: Int, iconSize: IconSize) {
self.title = ArticleStringFormatter.truncatedTitle(article)
self.attributedTitle = ArticleStringFormatter.attributedTruncatedTitle(article)
let truncatedSummary = ArticleStringFormatter.truncatedSummary(article)
if self.title.isEmpty && truncatedSummary.isEmpty {
self.summary = Self.noText
} else {
self.summary = truncatedSummary
}
self.dateString = ArticleStringFormatter.dateString(article.logicalDatePublished)
if let feedName = feedName {
self.feedName = ArticleStringFormatter.truncatedFeedName(feedName)
}
else {
self.feedName = ""
}
if let byline = byline {
self.byline = byline
} else {
self.byline = ""
}
self.showFeedName = showFeedName
self.showIcon = showIcon
self.iconImage = iconImage
self.read = article.status.read
self.starred = article.status.starred
self.numberOfLines = numberOfLines
self.iconSize = iconSize
}
init() { //Empty
self.title = ""
self.attributedTitle = NSAttributedString()
self.summary = ""
self.dateString = ""
self.feedName = ""
self.byline = ""
self.showFeedName = .none
self.showIcon = false
self.iconImage = nil
self.read = true
self.starred = false
self.numberOfLines = 0
self.iconSize = .medium
}
}

View File

@@ -0,0 +1,113 @@
//
// MainTimelineCellLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 4/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
protocol MainTimelineCellLayout {
var height: CGFloat {get}
var unreadIndicatorRect: CGRect {get}
var starRect: CGRect {get}
var iconImageRect: CGRect {get}
var titleRect: CGRect {get}
var summaryRect: CGRect {get}
var feedNameRect: CGRect {get}
var dateRect: CGRect {get}
}
extension MainTimelineCellLayout {
static func rectForUnreadIndicator(_ point: CGPoint) -> CGRect {
var r = CGRect.zero
r.size = CGSize(width: MainTimelineDefaultCellLayout.unreadCircleDimension, height: MainTimelineDefaultCellLayout.unreadCircleDimension)
r.origin.x = point.x
r.origin.y = point.y + 5
return r
}
static func rectForStar(_ point: CGPoint) -> CGRect {
var r = CGRect.zero
r.size.width = MainTimelineDefaultCellLayout.starDimension
r.size.height = MainTimelineDefaultCellLayout.starDimension
r.origin.x = floor(point.x - ((MainTimelineDefaultCellLayout.starDimension - MainTimelineDefaultCellLayout.unreadCircleDimension) / 2.0))
r.origin.y = point.y + 3
return r
}
static func rectForIconView(_ point: CGPoint, iconSize: IconSize) -> CGRect {
var r = CGRect.zero
r.size = iconSize.size
r.origin.x = point.x
r.origin.y = point.y + 4
return r
}
static func rectForTitle(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> (CGRect, Int) {
var r = CGRect.zero
if cellData.title.isEmpty {
return (r, 0)
}
r.origin = point
let sizeInfo = MultilineUILabelSizer.size(for: cellData.title, font: MainTimelineDefaultCellLayout.titleFont, numberOfLines: cellData.numberOfLines, width: Int(textAreaWidth))
r.size.width = textAreaWidth
r.size.height = sizeInfo.size.height
if sizeInfo.numberOfLinesUsed < 1 {
r.size.height = 0
}
return (r, sizeInfo.numberOfLinesUsed)
}
static func rectForSummary(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat, _ linesUsed: Int) -> CGRect {
let linesLeft = cellData.numberOfLines - linesUsed
var r = CGRect.zero
if cellData.summary.isEmpty || linesLeft < 1 {
return r
}
r.origin = point
let sizeInfo = MultilineUILabelSizer.size(for: cellData.summary, font: MainTimelineDefaultCellLayout.summaryFont, numberOfLines: linesLeft, width: Int(textAreaWidth))
r.size.width = textAreaWidth
r.size.height = sizeInfo.size.height
if sizeInfo.numberOfLinesUsed < 1 {
r.size.height = 0
}
return r
}
static func rectForFeedName(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
r.origin = point
let feedName = cellData.showFeedName == .feed ? cellData.feedName : cellData.byline
let size = SingleLineUILabelSizer.size(for: feedName, font: MainTimelineDefaultCellLayout.feedNameFont)
r.size = size
if r.size.width > textAreaWidth {
r.size.width = textAreaWidth
}
return r
}
}

View File

@@ -0,0 +1,126 @@
//
// MainTimelineDefaultCellLayout.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
struct MainTimelineDefaultCellLayout: MainTimelineCellLayout {
static let cellPadding = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 20)
static let unreadCircleMarginLeft = CGFloat(integerLiteral: 0)
static let unreadCircleDimension = CGFloat(integerLiteral: 12)
static let unreadCircleSize = CGSize(width: MainTimelineDefaultCellLayout.unreadCircleDimension, height: MainTimelineDefaultCellLayout.unreadCircleDimension)
static let unreadCircleMarginRight = CGFloat(integerLiteral: 8)
static let starDimension = CGFloat(integerLiteral: 16)
static let starSize = CGSize(width: MainTimelineDefaultCellLayout.starDimension, height: MainTimelineDefaultCellLayout.starDimension)
static let iconMarginRight = CGFloat(integerLiteral: 8)
static let iconCornerRadius = CGFloat(integerLiteral: 4)
static var titleFont: UIFont {
return UIFont.preferredFont(forTextStyle: .headline)
}
static let titleBottomMargin = CGFloat(integerLiteral: 1)
static var feedNameFont: UIFont {
return UIFont.preferredFont(forTextStyle: .footnote)
}
static let feedRightMargin = CGFloat(integerLiteral: 8)
static var dateFont: UIFont {
return UIFont.preferredFont(forTextStyle: .footnote)
}
static let dateMarginBottom = CGFloat(integerLiteral: 1)
static var summaryFont: UIFont {
return UIFont.preferredFont(forTextStyle: .body)
}
let height: CGFloat
let unreadIndicatorRect: CGRect
let starRect: CGRect
let iconImageRect: CGRect
let titleRect: CGRect
let summaryRect: CGRect
let feedNameRect: CGRect
let dateRect: CGRect
init(width: CGFloat, insets: UIEdgeInsets, cellData: MainTimelineCellData) {
var currentPoint = CGPoint.zero
currentPoint.x = MainTimelineDefaultCellLayout.cellPadding.left + insets.left + MainTimelineDefaultCellLayout.unreadCircleMarginLeft
currentPoint.y = MainTimelineDefaultCellLayout.cellPadding.top
// Unread Indicator and Star
self.unreadIndicatorRect = MainTimelineDefaultCellLayout.rectForUnreadIndicator(currentPoint)
self.starRect = MainTimelineDefaultCellLayout.rectForStar(currentPoint)
// Start the point at the beginning position of the main block
currentPoint.x += MainTimelineDefaultCellLayout.unreadCircleDimension + MainTimelineDefaultCellLayout.unreadCircleMarginRight
// Icon Image
if cellData.showIcon {
self.iconImageRect = MainTimelineDefaultCellLayout.rectForIconView(currentPoint, iconSize: cellData.iconSize)
currentPoint.x = self.iconImageRect.maxX + MainTimelineDefaultCellLayout.iconMarginRight
} else {
self.iconImageRect = CGRect.zero
}
let textAreaWidth = width - (currentPoint.x + MainTimelineDefaultCellLayout.cellPadding.right + insets.right)
// Title Text Block
let (titleRect, numberOfLinesForTitle) = MainTimelineDefaultCellLayout.rectForTitle(cellData, currentPoint, textAreaWidth)
self.titleRect = titleRect
// Summary Text Block
if self.titleRect != CGRect.zero {
currentPoint.y = self.titleRect.maxY + MainTimelineDefaultCellLayout.titleBottomMargin
}
self.summaryRect = MainTimelineDefaultCellLayout.rectForSummary(cellData, currentPoint, textAreaWidth, numberOfLinesForTitle)
var y = [self.titleRect, self.summaryRect].maxY()
if y == 0 {
y = iconImageRect.origin.y + iconImageRect.height
// Necessary calculation of either feed name or date since we are working with dynamic font-sizes
let tmp = MainTimelineDefaultCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
y -= tmp.height
}
currentPoint.y = y
// Feed Name and Pub Date
self.dateRect = MainTimelineDefaultCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
let feedNameWidth = textAreaWidth - (MainTimelineDefaultCellLayout.feedRightMargin + self.dateRect.size.width)
self.feedNameRect = MainTimelineDefaultCellLayout.rectForFeedName(cellData, currentPoint, feedNameWidth)
self.height = [self.iconImageRect, self.feedNameRect].maxY() + MainTimelineDefaultCellLayout.cellPadding.bottom
}
}
// MARK: - Calculate Rects
extension MainTimelineDefaultCellLayout {
static func rectForDate(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
let size = SingleLineUILabelSizer.size(for: cellData.dateString, font: MainTimelineDefaultCellLayout.dateFont)
r.size = size
r.origin.x = (point.x + textAreaWidth) - size.width
r.origin.y = point.y
return r
}
}

View File

@@ -0,0 +1,310 @@
//
// MainTimelineTableViewCell.swift
// NetNewsWire
//
// Created by Brent Simmons on 8/31/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
class MainTimelineTableViewCell: VibrantTableViewCell {
private let titleView = MainTimelineTableViewCell.multiLineUILabel()
private let summaryView = MainTimelineTableViewCell.multiLineUILabel()
private let unreadIndicatorView = MainUnreadIndicatorView(frame: CGRect.zero)
private let dateView = MainTimelineTableViewCell.singleLineUILabel()
private let feedNameView = MainTimelineTableViewCell.singleLineUILabel()
private lazy var iconView = IconView()
private lazy var starView = {
return NonIntrinsicImageView(image: AppAssets.timelineStarImage)
}()
private var unreadIndicatorPropertyAnimator: UIViewPropertyAnimator?
private var starViewPropertyAnimator: UIViewPropertyAnimator?
var cellData: MainTimelineCellData! {
didSet {
updateSubviews()
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func prepareForReuse() {
unreadIndicatorPropertyAnimator?.stopAnimation(true)
unreadIndicatorPropertyAnimator = nil
unreadIndicatorView.isHidden = true
starViewPropertyAnimator?.stopAnimation(true)
starViewPropertyAnimator = nil
starView.isHidden = true
}
override var frame: CGRect {
didSet {
setNeedsLayout()
}
}
override func updateVibrancy(animated: Bool) {
updateLabelVibrancy(titleView, color: labelColor, animated: animated)
updateLabelVibrancy(summaryView, color: labelColor, animated: animated)
updateLabelVibrancy(dateView, color: secondaryLabelColor, animated: animated)
updateLabelVibrancy(feedNameView, color: secondaryLabelColor, animated: animated)
if animated {
UIView.animate(withDuration: Self.duration) {
if self.isHighlighted || self.isSelected {
self.unreadIndicatorView.backgroundColor = AppAssets.vibrantTextColor
} else {
self.unreadIndicatorView.backgroundColor = AppAssets.secondaryAccentColor
}
}
} else {
if self.isHighlighted || self.isSelected {
self.unreadIndicatorView.backgroundColor = AppAssets.vibrantTextColor
} else {
self.unreadIndicatorView.backgroundColor = AppAssets.secondaryAccentColor
}
}
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = updatedLayout(width: size.width)
return CGSize(width: size.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = updatedLayout(width: bounds.width)
unreadIndicatorView.setFrameIfNotEqual(layout.unreadIndicatorRect)
starView.setFrameIfNotEqual(layout.starRect)
iconView.setFrameIfNotEqual(layout.iconImageRect)
setFrame(for: titleView, rect: layout.titleRect)
setFrame(for: summaryView, rect: layout.summaryRect)
feedNameView.setFrameIfNotEqual(layout.feedNameRect)
dateView.setFrameIfNotEqual(layout.dateRect)
separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
func setIconImage(_ image: IconImage) {
iconView.iconImage = image
}
}
// MARK: - Private
private extension MainTimelineTableViewCell {
static func singleLineUILabel() -> UILabel {
let label = NonIntrinsicLabel()
label.lineBreakMode = .byTruncatingTail
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
return label
}
static func multiLineUILabel() -> UILabel {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.lineBreakMode = .byTruncatingTail
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
return label
}
func setFrame(for label: UILabel, rect: CGRect) {
if Int(floor(rect.height)) == 0 || Int(floor(rect.width)) == 0 {
hideView(label)
} else {
showView(label)
label.setFrameIfNotEqual(rect)
}
}
func addSubviewAtInit(_ view: UIView, hidden: Bool) {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = hidden
}
func commonInit() {
addSubviewAtInit(titleView, hidden: false)
addSubviewAtInit(summaryView, hidden: true)
addSubviewAtInit(unreadIndicatorView, hidden: true)
addSubviewAtInit(dateView, hidden: false)
addSubviewAtInit(feedNameView, hidden: true)
addSubviewAtInit(iconView, hidden: true)
addSubviewAtInit(starView, hidden: true)
}
func updatedLayout(width: CGFloat) -> MainTimelineCellLayout {
if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory {
return MainTimelineAccessibilityCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
} else {
return MainTimelineDefaultCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
}
}
func updateTitleView() {
titleView.font = MainTimelineDefaultCellLayout.titleFont
titleView.textColor = labelColor
updateTextFieldAttributedText(titleView, cellData?.attributedTitle)
}
func updateSummaryView() {
summaryView.font = MainTimelineDefaultCellLayout.summaryFont
summaryView.textColor = labelColor
updateTextFieldText(summaryView, cellData?.summary)
}
func updateDateView() {
dateView.font = MainTimelineDefaultCellLayout.dateFont
dateView.textColor = secondaryLabelColor
updateTextFieldText(dateView, cellData.dateString)
}
func updateTextFieldText(_ label: UILabel, _ text: String?) {
let s = text ?? ""
if label.text != s {
label.text = s
setNeedsLayout()
}
}
func updateTextFieldAttributedText(_ label: UILabel, _ text: NSAttributedString?) {
var s = text ?? NSAttributedString(string: "")
if let fieldFont = label.font {
s = s.adding(font: fieldFont)
}
if label.attributedText != s {
label.attributedText = s
setNeedsLayout()
}
}
func updateFeedNameView() {
switch cellData.showFeedName {
case .feed:
showView(feedNameView)
feedNameView.font = MainTimelineDefaultCellLayout.feedNameFont
feedNameView.textColor = secondaryLabelColor
updateTextFieldText(feedNameView, cellData.feedName)
case .byline:
showView(feedNameView)
feedNameView.font = MainTimelineDefaultCellLayout.feedNameFont
feedNameView.textColor = secondaryLabelColor
updateTextFieldText(feedNameView, cellData.byline)
case .none:
hideView(feedNameView)
}
}
func updateUnreadIndicator() {
if !unreadIndicatorView.isHidden && cellData.read && !cellData.starred {
unreadIndicatorPropertyAnimator = UIViewPropertyAnimator(duration: 0.66, curve: .easeInOut) { [weak self] in
self?.unreadIndicatorView.alpha = 0
}
unreadIndicatorPropertyAnimator?.addCompletion { [weak self] _ in
self?.unreadIndicatorView.isHidden = true
self?.unreadIndicatorView.alpha = 1
self?.unreadIndicatorPropertyAnimator = nil
}
unreadIndicatorPropertyAnimator?.startAnimation()
} else {
unreadIndicatorView.alpha = 1
showOrHideView(unreadIndicatorView, cellData.read || cellData.starred)
}
}
func updateStarView() {
if !starView.isHidden && cellData.read && !cellData.starred {
starViewPropertyAnimator = UIViewPropertyAnimator(duration: 0.66, curve: .easeInOut) { [weak self] in
self?.starView.alpha = 0
}
starViewPropertyAnimator?.addCompletion { [weak self] _ in
self?.starView.isHidden = true
self?.starView.alpha = 1
self?.starViewPropertyAnimator = nil
}
starViewPropertyAnimator?.startAnimation()
} else {
starView.alpha = 1
showOrHideView(starView, !cellData.starred)
}
}
func updateIconImage() {
guard let image = cellData.iconImage, cellData.showIcon else {
makeIconEmpty()
return
}
showView(iconView)
if iconView.iconImage !== cellData.iconImage {
iconView.iconImage = image
setNeedsLayout()
}
}
func updateAccessiblityLabel() {
let starredStatus = cellData.starred ? "\(NSLocalizedString("Starred", comment: "Starred article for accessibility")), " : ""
let unreadStatus = cellData.read ? "" : "\(NSLocalizedString("Unread", comment: "Unread")), "
let label = starredStatus + unreadStatus + "\(cellData.feedName), \(cellData.title), \(cellData.summary), \(cellData.dateString)"
accessibilityLabel = label
}
func makeIconEmpty() {
if iconView.iconImage != nil {
iconView.iconImage = nil
setNeedsLayout()
}
hideView(iconView)
}
func hideView(_ view: UIView) {
if !view.isHidden {
view.isHidden = true
}
}
func showView(_ view: UIView) {
if view.isHidden {
view.isHidden = false
}
}
func showOrHideView(_ view: UIView, _ shouldHide: Bool) {
shouldHide ? hideView(view) : showView(view)
}
func updateSubviews() {
updateTitleView()
updateSummaryView()
updateDateView()
updateFeedNameView()
updateUnreadIndicator()
updateStarView()
updateIconImage()
updateAccessiblityLabel()
}
}

View File

@@ -0,0 +1,19 @@
//
// MainUnreadIndicatorView.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/16/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
class MainUnreadIndicatorView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = frame.size.width / 2.0
clipsToBounds = true
}
}

View File

@@ -0,0 +1,182 @@
//
// UILabelSizerSpecifier.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/19/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import UIKit
// Get the height of an NSTextField given a string, font, and width.
// Uses a cache. Avoids actually measuring text as much as possible.
// Main thread only.
typealias WidthHeightCache = [Int: Int] // width: height
private struct UILabelSizerSpecifier: Hashable {
let numberOfLines: Int
let font: UIFont
}
struct TextFieldSizeInfo {
let size: CGSize // Integral size (ceiled)
let numberOfLinesUsed: Int // A two-line text field may only use one line, for instance. This would equal 1, then.
}
final class MultilineUILabelSizer {
private let numberOfLines: Int
private let font: UIFont
private let singleLineHeightEstimate: Int
private let doubleLineHeightEstimate: Int
private var cache = [String: WidthHeightCache]() // Each string has a cache.
private static var sizers = [UILabelSizerSpecifier: MultilineUILabelSizer]()
private init(numberOfLines: Int, font: UIFont) {
self.numberOfLines = numberOfLines
self.font = font
self.singleLineHeightEstimate = MultilineUILabelSizer.calculateHeight("AqLjJ0/y", 200, font)
self.doubleLineHeightEstimate = MultilineUILabelSizer.calculateHeight("AqLjJ0/y\nAqLjJ0/y", 200, font)
}
static func size(for string: String, font: UIFont, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
}
static func emptyCache() {
sizers = [UILabelSizerSpecifier: MultilineUILabelSizer]()
}
}
// MARK: - Private
private extension MultilineUILabelSizer {
static func sizer(numberOfLines: Int, font: UIFont) -> MultilineUILabelSizer {
let specifier = UILabelSizerSpecifier(numberOfLines: numberOfLines, font: font)
if let cachedSizer = sizers[specifier] {
return cachedSizer
}
let newSizer = MultilineUILabelSizer(numberOfLines: numberOfLines, font: font)
sizers[specifier] = newSizer
return newSizer
}
func sizeInfo(for string: String, width: Int) -> TextFieldSizeInfo {
let textFieldHeight = height(for: string, width: width)
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
let size = CGSize(width: width, height: textFieldHeight)
let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
return sizeInfo
}
func height(for string: String, width: Int) -> Int {
if cache[string] == nil {
cache[string] = WidthHeightCache()
}
if let height = cache[string]![width] {
return height
}
if let height = heightConsideringNeighbors(cache[string]!, width) {
return height
}
var height = MultilineUILabelSizer.calculateHeight(string, width, font)
if numberOfLines != 0 {
let maxHeight = singleLineHeightEstimate * numberOfLines
if height > maxHeight {
height = maxHeight
}
}
cache[string]![width] = height
return height
}
static func calculateHeight(_ string: String, _ width: Int, _ font: UIFont) -> Int {
let height = string.height(withConstrainedWidth: CGFloat(width), font: font)
return Int(ceil(height))
}
func numberOfLines(for height: Int) -> Int {
// Well have to see if this really works reliably.
let averageHeight = CGFloat(doubleLineHeightEstimate) / 2.0
let lines = Int(round(CGFloat(height) / averageHeight))
return lines
}
func heightIsProbablySingleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, singleLineHeightEstimate)
}
func heightIsProbablyDoubleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, doubleLineHeightEstimate)
}
func heightIsProbablyEqualToEstimate(_ height: Int, _ estimate: Int) -> Bool {
let slop = 4
let minimum = estimate - slop
let maximum = estimate + slop
return height >= minimum && height <= maximum
}
func heightConsideringNeighbors(_ heightCache: WidthHeightCache, _ width: Int) -> Int? {
// Given width, if the height at width - something and width + something is equal,
// then that height must be correct for the given width.
// Also:
// If a narrower neighbors height is single line height, then this wider width must also be single-line height.
// If a wider neighbors height is double line height, and numberOfLines == 2, then this narrower width must able be double-line height.
var smallNeighbor = (width: 0, height: 0)
var largeNeighbor = (width: 0, height: 0)
for (oneWidth, oneHeight) in heightCache {
if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
return oneHeight
}
if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
return oneHeight
}
if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
smallNeighbor = (oneWidth, oneHeight)
}
else if oneWidth > width && (oneWidth < largeNeighbor.width || largeNeighbor.width == 0) {
largeNeighbor = (oneWidth, oneHeight)
}
if smallNeighbor.width != 0 && smallNeighbor.height == largeNeighbor.height {
return smallNeighbor.height
}
}
return nil
}
}

View File

@@ -0,0 +1,64 @@
//
// SingleLineUILabelSizer.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/19/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import UIKit
// Get the size of an UILabel configured with a specific font with a specific size.
// Uses a cache.
// Main thready only.
final class SingleLineUILabelSizer {
let font: UIFont
private var cache = [String: CGSize]()
init(font: UIFont) {
self.font = font
}
func size(for text: String) -> CGSize {
if let cachedSize = cache[text] {
return cachedSize
}
let height = text.height(withConstrainedWidth: .greatestFiniteMagnitude, font: font)
let width = text.width(withConstrainedHeight: .greatestFiniteMagnitude, font: font)
let calculatedSize = CGSize(width: ceil(width), height: ceil(height))
cache[text] = calculatedSize
return calculatedSize
}
static private var sizers = [UIFont: SingleLineUILabelSizer]()
static func sizer(for font: UIFont) -> SingleLineUILabelSizer {
if let cachedSizer = sizers[font] {
return cachedSizer
}
let newSizer = SingleLineUILabelSizer(font: font)
sizers[font] = newSizer
return newSizer
}
// Use this call. Its easiest.
static func size(for text: String, font: UIFont) -> CGSize {
return sizer(for: font).size(for: text)
}
static func emptyCache() {
sizers = [UIFont: SingleLineUILabelSizer]()
}
}

View File

@@ -0,0 +1,18 @@
//
// MainTimelineDataSource.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 8/30/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
class MainTimelineDataSource<SectionIdentifierType, ItemIdentifierType>: UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable {
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
}

View File

@@ -0,0 +1,64 @@
//
// MainTimelineTitleView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/21/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
class MainTimelineTitleView: UIView {
@IBOutlet weak var iconView: IconView!
@IBOutlet weak var label: UILabel!
@IBOutlet weak var unreadCountView: MainTimelineUnreadCountView!
@available(iOS 13.4, *)
private lazy var pointerInteraction: UIPointerInteraction = {
UIPointerInteraction(delegate: self)
}()
override var accessibilityLabel: String? {
set { }
get {
if let name = label.text {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessiblity")
return "\(name) \(unreadCountView.unreadCount) \(unreadLabel)"
}
else {
return nil
}
}
}
func buttonize() {
heightAnchor.constraint(equalToConstant: 40.0).isActive = true
accessibilityTraits = .button
if #available(iOS 13.4, *) {
addInteraction(pointerInteraction)
}
}
func debuttonize() {
heightAnchor.constraint(equalToConstant: 40.0).isActive = true
accessibilityTraits.remove(.button)
if #available(iOS 13.4, *) {
removeInteraction(pointerInteraction)
}
}
}
extension MainTimelineTitleView: UIPointerInteractionDelegate {
@available(iOS 13.4, *)
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
var rect = self.frame
rect.origin.x = rect.origin.x - 10
rect.size.width = rect.width + 20
return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: .roundedRect(rect))
}
}

View File

@@ -0,0 +1,58 @@
<?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_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<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"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="MainTimelineTitleView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="190" height="38"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="250" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5F6-2v-qSS">
<rect key="frame" x="28" y="9" width="43.5" height="20"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="z9o-XA-3t4" customClass="MainTimelineUnreadCountView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="79.5" y="9" width="110.5" height="20"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5gI-Wl-lnK" customClass="IconView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="9" width="20" height="20"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="20" id="TgA-1l-WQJ"/>
<constraint firstAttribute="height" constant="20" id="VUB-ip-zXU"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="5gI-Wl-lnK" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="1Yh-ha-Pu8"/>
<constraint firstItem="5F6-2v-qSS" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="CeZ-D5-NOV"/>
<constraint firstItem="z9o-XA-3t4" firstAttribute="leading" secondItem="5F6-2v-qSS" secondAttribute="trailing" constant="8" symbolic="YES" id="CiV-5P-T1S"/>
<constraint firstAttribute="trailing" secondItem="z9o-XA-3t4" secondAttribute="trailing" id="OVL-Ac-Rtt"/>
<constraint firstItem="z9o-XA-3t4" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="ZkY-jG-eZO"/>
<constraint firstItem="5gI-Wl-lnK" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="eQU-mX-qmd"/>
<constraint firstItem="5F6-2v-qSS" firstAttribute="leading" secondItem="5gI-Wl-lnK" secondAttribute="trailing" constant="8" id="fVr-vW-alg"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="iconView" destination="5gI-Wl-lnK" id="IiR-qS-d22"/>
<outlet property="label" destination="5F6-2v-qSS" id="ec7-8Y-PRv"/>
<outlet property="unreadCountView" destination="z9o-XA-3t4" id="JBy-aa-feL"/>
</connections>
<point key="canvasLocation" x="14.492753623188406" y="-224.33035714285714"/>
</view>
</objects>
</document>

View File

@@ -0,0 +1,39 @@
//
// MainTimelineUnreadCountView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/30/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
class MainTimelineUnreadCountView: MainFeedUnreadCountView {
override var padding: UIEdgeInsets {
return UIEdgeInsets(top: 2.0, left: 9.0, bottom: 2.0, right: 9.0)
}
override var textColor: UIColor {
return UIColor.systemBackground
}
override var intrinsicContentSize: CGSize {
return contentSize
}
override func draw(_ dirtyRect: CGRect) {
let cornerRadii = CGSize(width: cornerRadius, height: cornerRadius)
let rect = CGRect(x: 1, y: 1, width: bounds.width - 2, height: bounds.height - 2)
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: cornerRadii)
AppAssets.primaryAccentColor.setFill()
path.fill()
if unreadCount > 0 {
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
}
}
}

View File

@@ -0,0 +1,984 @@
//
// MainTimelineViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/8/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import Account
import Articles
class MainTimelineViewController: UITableViewController, UndoableCommandRunner {
private var numberOfTextLines = 0
private var iconSize = IconSize.medium
private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:)))
private var refreshProgressView: RefreshProgressView?
@IBOutlet weak var markAllAsReadButton: UIBarButtonItem!
private var filterButton: UIBarButtonItem!
private var firstUnreadButton: UIBarButtonItem!
private lazy var dataSource = makeDataSource()
private let searchController = UISearchController(searchResultsController: nil)
weak var coordinator: SceneCoordinator!
var undoableCommands = [UndoableCommand]()
let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0)
private let keyboardManager = KeyboardManager(type: .timeline)
override var keyCommands: [UIKeyCommand]? {
// If the first responder is the WKWebView (PreloadedWebView) we don't want to supply any keyboard
// commands that the system is looking for by going up the responder chain. They will interfere with
// the WKWebViews built in hardware keyboard shortcuts, specifically the up and down arrow keys.
guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { return nil }
return keyboardManager.keyCommands
}
override var canBecomeFirstResponder: Bool {
return true
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .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(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
// Initialize Programmatic Buttons
filterButton = UIBarButtonItem(image: AppAssets.filterInactiveImage, style: .plain, target: self, action: #selector(toggleFilter(_:)))
firstUnreadButton = UIBarButtonItem(image: AppAssets.nextUnreadArticleImage, style: .plain, target: self, action: #selector(firstUnread(_:)))
// Setup the Search Controller
searchController.delegate = self
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.delegate = self
searchController.searchBar.placeholder = NSLocalizedString("Search Articles", comment: "Search Articles")
searchController.searchBar.scopeButtonTitles = [
NSLocalizedString("Here", comment: "Here"),
NSLocalizedString("All Articles", comment: "All Articles")
]
navigationItem.searchController = searchController
definesPresentationContext = true
// Configure the table
tableView.dataSource = dataSource
if #available(iOS 15.0, *) {
tableView.isPrefetchingEnabled = false
}
numberOfTextLines = AppDefaults.shared.timelineNumberOfLines
iconSize = AppDefaults.shared.timelineIconSize
resetEstimatedRowHeight()
if let titleView = Bundle.main.loadNibNamed("MainTimelineTitleView", owner: self, options: nil)?[0] as? MainTimelineTitleView {
navigationItem.titleView = titleView
}
refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
configureToolbar()
resetUI(resetScroll: true)
// Load the table and then scroll to the saved position if available
applyChanges(animated: false) {
if let restoreIndexPath = self.coordinator.timelineMiddleIndexPath {
self.tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
}
}
// Disable swipe back on iPad Mice
if #available(iOS 13.4, *) {
guard let gesture = self.navigationController?.interactivePopGestureRecognizer as? UIPanGestureRecognizer else {
return
}
gesture.allowedScrollTypesMask = []
}
}
override func viewWillAppear(_ animated: Bool) {
self.navigationController?.isToolbarHidden = false
// If the nav bar is hidden, fade it in to avoid it showing stuff as it is getting laid out
if navigationController?.navigationBar.isHidden ?? false {
navigationController?.navigationBar.alpha = 0
}
super.viewWillAppear(animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
coordinator.isTimelineViewControllerPending = false
if navigationController?.navigationBar.alpha == 0 {
UIView.animate(withDuration: 0.5) {
self.navigationController?.navigationBar.alpha = 1
}
}
}
// MARK: Actions
@objc func openInBrowser(_ sender: Any?) {
coordinator.showBrowserForCurrentArticle()
}
@objc func openInAppBrowser(_ sender: Any?) {
coordinator.showInAppBrowser()
}
@IBAction func toggleFilter(_ sender: Any) {
coordinator.toggleReadArticlesFilter()
}
@IBAction func markAllAsRead(_ sender: Any) {
let title = NSLocalizedString("Mark All as Read", comment: "Mark All as Read")
if let source = sender as? UIBarButtonItem {
MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: source) { [weak self] in
self?.coordinator.markAllAsReadInTimeline()
}
}
if let _ = sender as? UIKeyCommand {
guard let indexPath = tableView.indexPathForSelectedRow, let contentView = tableView.cellForRow(at: indexPath)?.contentView else {
return
}
MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
self?.coordinator.markAllAsReadInTimeline()
}
}
}
@IBAction func firstUnread(_ sender: Any) {
coordinator.selectFirstUnread()
}
@objc func refreshAccounts(_ sender: Any) {
refreshControl?.endRefreshing()
// This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl.
// If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appDelegate.manualRefresh(errorHandler: ErrorHandler.present(self))
}
}
// MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) {
coordinator.selectPrevArticle()
}
@objc func selectNextDown(_ sender: Any?) {
coordinator.selectNextArticle()
}
@objc func navigateToSidebar(_ sender: Any?) {
coordinator.navigateToFeeds()
}
@objc func navigateToDetail(_ sender: Any?) {
coordinator.navigateToDetail()
}
@objc func showFeedInspector(_ sender: Any?) {
coordinator.showFeedInspector()
}
// MARK: API
func restoreSelectionIfNecessary(adjustScroll: Bool) {
if let article = coordinator.currentArticle, let indexPath = dataSource.indexPath(for: article) {
if adjustScroll {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: [])
} else {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
}
}
}
func reinitializeArticles(resetScroll: Bool) {
resetUI(resetScroll: resetScroll)
}
func reloadArticles(animated: Bool) {
applyChanges(animated: animated)
}
func updateArticleSelection(animations: Animations) {
if let article = coordinator.currentArticle, let indexPath = dataSource.indexPath(for: article) {
if tableView.indexPathForSelectedRow != indexPath {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations)
}
} else {
tableView.selectRow(at: nil, animated: animations.contains(.select), scrollPosition: .none)
}
updateUI()
}
func updateUI() {
refreshProgressView?.update()
updateTitleUnreadCount()
updateToolbar()
}
func hideSearch() {
navigationItem.searchController?.isActive = false
}
func showSearchAll() {
navigationItem.searchController?.isActive = true
navigationItem.searchController?.searchBar.selectedScopeButtonIndex = 1
navigationItem.searchController?.searchBar.becomeFirstResponder()
}
func focus() {
becomeFirstResponder()
}
// MARK: - Table view
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let article = dataSource.itemIdentifier(for: indexPath) else { return nil }
guard !article.status.read || article.isAvailableToMarkUnread else { return nil }
// Set up the read action
let readTitle = article.status.read ?
NSLocalizedString("Mark as Unread", comment: "Mark as Unread") :
NSLocalizedString("Mark as Read", comment: "Mark as Read")
let readAction = UIContextualAction(style: .normal, title: readTitle) { [weak self] (action, view, completion) in
self?.coordinator.toggleRead(article)
completion(true)
}
readAction.image = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
readAction.backgroundColor = AppAssets.primaryAccentColor
return UISwipeActionsConfiguration(actions: [readAction])
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let article = dataSource.itemIdentifier(for: indexPath) else { return nil }
// Set up the star action
let starTitle = article.status.starred ?
NSLocalizedString("Unstar", comment: "Unstar") :
NSLocalizedString("Star", comment: "Star")
let starAction = UIContextualAction(style: .normal, title: starTitle) { [weak self] (action, view, completion) in
self?.coordinator.toggleStar(article)
completion(true)
}
starAction.image = article.status.starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
starAction.backgroundColor = AppAssets.starColor
// Set up the read action
let moreTitle = NSLocalizedString("More", comment: "More")
let moreAction = UIContextualAction(style: .normal, title: moreTitle) { [weak self] (action, view, completion) in
if let self = self {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if let popoverController = alert.popoverPresentationController {
popoverController.sourceView = view
popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1)
}
if let action = self.markAboveAsReadAlertAction(article, indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.markBelowAsReadAlertAction(article, indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.discloseFeedAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.markAllInFeedAsReadAlertAction(article, indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.openInBrowserAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.shareAlertAction(article, indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel) { _ in
completion(true)
})
self.present(alert, animated: true)
}
}
moreAction.image = AppAssets.moreImage
moreAction.backgroundColor = UIColor.systemGray
return UISwipeActionsConfiguration(actions: [starAction, moreAction])
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let article = dataSource.itemIdentifier(for: indexPath) else { return nil }
return UIContextMenuConfiguration(identifier: indexPath.row as NSCopying, previewProvider: nil, actionProvider: { [weak self] suggestedActions in
guard let self = self else { return nil }
var menuElements = [UIMenuElement]()
var markActions = [UIAction]()
if let action = self.toggleArticleReadStatusAction(article) {
markActions.append(action)
}
markActions.append(self.toggleArticleStarStatusAction(article))
if let action = self.markAboveAsReadAction(article, indexPath: indexPath) {
markActions.append(action)
}
if let action = self.markBelowAsReadAction(article, indexPath: indexPath) {
markActions.append(action)
}
menuElements.append(UIMenu(title: "", options: .displayInline, children: markActions))
var secondaryActions = [UIAction]()
if let action = self.discloseFeedAction(article) {
secondaryActions.append(action)
}
if let action = self.markAllInFeedAsReadAction(article, indexPath: indexPath) {
secondaryActions.append(action)
}
if !secondaryActions.isEmpty {
menuElements.append(UIMenu(title: "", options: .displayInline, children: secondaryActions))
}
var copyActions = [UIAction]()
if let action = self.copyArticleURLAction(article) {
copyActions.append(action)
}
if let action = self.copyExternalURLAction(article) {
copyActions.append(action)
}
if !copyActions.isEmpty {
menuElements.append(UIMenu(title: "", options: .displayInline, children: copyActions))
}
if let action = self.openInBrowserAction(article) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [action]))
}
if let action = self.shareAction(article, indexPath: indexPath) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [action]))
}
return UIMenu(title: "", children: menuElements)
})
}
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard let row = configuration.identifier as? Int,
let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) else {
return nil
}
return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell))
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
becomeFirstResponder()
let article = dataSource.itemIdentifier(for: indexPath)
coordinator.selectArticle(article, animations: [.scroll, .select, .navigation])
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
}
// 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>, !articleIDs.isEmpty else {
return
}
let visibleArticles = tableView.indexPathsForVisibleRows!.compactMap { return dataSource.itemIdentifier(for: $0) }
let visibleUpdatedArticles = visibleArticles.filter { articleIDs.contains($0.articleID) }
for article in visibleUpdatedArticles {
if let indexPath = dataSource.indexPath(for: article) {
if let cell = tableView.cellForRow(at: indexPath) as? MainTimelineTableViewCell {
configure(cell, article: article)
}
}
}
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
if let titleView = navigationItem.titleView as? MainTimelineTitleView {
titleView.iconView.iconImage = coordinator.timelineIconImage
}
guard let feed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed else {
return
}
tableView.indexPathsForVisibleRows?.forEach { indexPath in
guard let article = dataSource.itemIdentifier(for: indexPath) else {
return
}
if article.webFeed == feed, let cell = tableView.cellForRow(at: indexPath) as? MainTimelineTableViewCell, let image = iconImageFor(article) {
cell.setIconImage(image)
}
}
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
guard coordinator.showIcons, let avatarURL = note.userInfo?[UserInfoKey.url] as? String else {
return
}
tableView.indexPathsForVisibleRows?.forEach { indexPath in
guard let article = dataSource.itemIdentifier(for: indexPath), let authors = article.authors, !authors.isEmpty else {
return
}
for author in authors {
if author.avatarURL == avatarURL, let cell = tableView.cellForRow(at: indexPath) as? MainTimelineTableViewCell, let image = iconImageFor(article) {
cell.setIconImage(image)
}
}
}
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
if let titleView = navigationItem.titleView as? MainTimelineTitleView {
titleView.iconView.iconImage = coordinator.timelineIconImage
}
if coordinator.showIcons {
queueReloadAvailableCells()
}
}
@objc func userDefaultsDidChange(_ note: Notification) {
DispatchQueue.main.async {
if self.numberOfTextLines != AppDefaults.shared.timelineNumberOfLines || self.iconSize != AppDefaults.shared.timelineIconSize {
self.numberOfTextLines = AppDefaults.shared.timelineNumberOfLines
self.iconSize = AppDefaults.shared.timelineIconSize
self.resetEstimatedRowHeight()
self.reloadAllVisibleCells()
}
self.updateToolbar()
}
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
reloadAllVisibleCells()
}
@objc func displayNameDidChange(_ note: Notification) {
if let titleView = navigationItem.titleView as? MainTimelineTitleView {
titleView.label.text = coordinator.timelineFeed?.nameForDisplay
}
}
@objc func willEnterForeground(_ note: Notification) {
updateUI()
}
@objc func scrollPositionDidChange() {
coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow()
}
// MARK: Reloading
func queueReloadAvailableCells() {
CoalescingQueue.standard.add(self, #selector(reloadAllVisibleCells))
}
@objc private func reloadAllVisibleCells() {
let visibleArticles = tableView.indexPathsForVisibleRows!.compactMap { return dataSource.itemIdentifier(for: $0) }
reloadCells(visibleArticles)
}
private func reloadCells(_ articles: [Article]) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems(articles)
dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: false)
}
}
// MARK: Cell Configuring
private func resetEstimatedRowHeight() {
let longTitle = "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeCellData = MainTimelineCellData(article: prototypeArticle, showFeedName: .feed, feedName: "Prototype Feed Name", byline: nil, iconImage: nil, showIcon: false, numberOfLines: numberOfTextLines, iconSize: iconSize)
if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory {
let layout = MainTimelineAccessibilityCellLayout(width: tableView.bounds.width, insets: tableView.safeAreaInsets, cellData: prototypeCellData)
tableView.estimatedRowHeight = layout.height
} else {
let layout = MainTimelineDefaultCellLayout(width: tableView.bounds.width, insets: tableView.safeAreaInsets, cellData: prototypeCellData)
tableView.estimatedRowHeight = layout.height
}
}
}
// MARK: Searching
extension MainTimelineViewController: UISearchControllerDelegate {
func willPresentSearchController(_ searchController: UISearchController) {
coordinator.beginSearching()
searchController.searchBar.showsScopeBar = true
}
func willDismissSearchController(_ searchController: UISearchController) {
coordinator.endSearching()
searchController.searchBar.showsScopeBar = false
}
}
extension MainTimelineViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
let searchScope = SearchScope(rawValue: searchController.searchBar.selectedScopeButtonIndex)!
coordinator.searchArticles(searchController.searchBar.text!, searchScope)
}
}
extension MainTimelineViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
let searchScope = SearchScope(rawValue: selectedScope)!
coordinator.searchArticles(searchBar.text!, searchScope)
}
}
// MARK: Private
private extension MainTimelineViewController {
func configureToolbar() {
guard !(splitViewController?.isCollapsed ?? true) else {
return
}
guard let refreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as? RefreshProgressView else {
return
}
self.refreshProgressView = refreshProgressView
let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
toolbarItems?.insert(refreshProgressItemButton, at: 2)
}
func resetUI(resetScroll: Bool) {
title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline"
if let titleView = navigationItem.titleView as? MainTimelineTitleView {
let timelineIconImage = coordinator.timelineIconImage
titleView.iconView.iconImage = timelineIconImage
if let preferredColor = timelineIconImage?.preferredColor {
titleView.iconView.tintColor = UIColor(cgColor: preferredColor)
} else {
titleView.iconView.tintColor = nil
}
titleView.label.text = coordinator.timelineFeed?.nameForDisplay
updateTitleUnreadCount()
if coordinator.timelineFeed is WebFeed {
titleView.buttonize()
titleView.addGestureRecognizer(feedTapGestureRecognizer)
} else {
titleView.debuttonize()
titleView.removeGestureRecognizer(feedTapGestureRecognizer)
}
navigationItem.titleView = titleView
}
switch coordinator.timelineDefaultReadFilterType {
case .none, .read:
navigationItem.rightBarButtonItem = filterButton
case .alwaysRead:
navigationItem.rightBarButtonItem = nil
}
if coordinator.isReadArticlesFiltered {
filterButton?.image = AppAssets.filterActiveImage
filterButton?.accLabelText = NSLocalizedString("Selected - Filter Read Articles", comment: "Selected - Filter Read Articles")
} else {
filterButton?.image = AppAssets.filterInactiveImage
filterButton?.accLabelText = NSLocalizedString("Filter Read Articles", comment: "Filter Read Articles")
}
tableView.selectRow(at: nil, animated: false, scrollPosition: .top)
if resetScroll {
let snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.count > 0 && snapshot.itemIdentifiers(inSection: 0).count > 0 {
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
}
}
updateToolbar()
}
func updateToolbar() {
guard firstUnreadButton != nil else { return }
markAllAsReadButton.isEnabled = coordinator.isTimelineUnreadAvailable
firstUnreadButton.isEnabled = coordinator.isTimelineUnreadAvailable
if coordinator.isRootSplitCollapsed {
if let toolbarItems = toolbarItems, toolbarItems.last != firstUnreadButton {
var items = toolbarItems
items.append(firstUnreadButton)
setToolbarItems(items, animated: false)
}
} else {
if let toolbarItems = toolbarItems, toolbarItems.last == firstUnreadButton {
let items = Array(toolbarItems[0..<toolbarItems.count - 1])
setToolbarItems(items, animated: false)
}
}
}
func updateTitleUnreadCount() {
if let titleView = navigationItem.titleView as? MainTimelineTitleView {
titleView.unreadCountView.unreadCount = coordinator.timelineUnreadCount
}
}
func applyChanges(animated: Bool, completion: (() -> Void)? = nil) {
if coordinator.articles.count == 0 {
tableView.rowHeight = tableView.estimatedRowHeight
} else {
tableView.rowHeight = UITableView.automaticDimension
}
var snapshot = NSDiffableDataSourceSnapshot<Int, Article>()
snapshot.appendSections([0])
snapshot.appendItems(coordinator.articles, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: animated) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: false)
completion?()
}
}
func makeDataSource() -> UITableViewDiffableDataSource<Int, Article> {
let dataSource: UITableViewDiffableDataSource<Int, Article> =
MainTimelineDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, article in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MainTimelineTableViewCell
self?.configure(cell, article: article)
return cell
})
dataSource.defaultRowAnimation = .middle
return dataSource
}
func configure(_ cell: MainTimelineTableViewCell, article: Article) {
let iconImage = iconImageFor(article)
let showFeedNames = coordinator.showFeedNames
let showIcon = coordinator.showIcons && iconImage != nil
cell.cellData = MainTimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.webFeed?.nameForDisplay, byline: article.byline(), iconImage: iconImage, showIcon: showIcon, numberOfLines: numberOfTextLines, iconSize: iconSize)
}
func iconImageFor(_ article: Article) -> IconImage? {
if !coordinator.showIcons {
return nil
}
return article.iconImage()
}
func toggleArticleReadStatusAction(_ article: Article) -> UIAction? {
guard !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 image = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
let action = UIAction(title: title, image: image) { [weak self] action in
self?.coordinator.toggleRead(article)
}
return action
}
func toggleArticleStarStatusAction(_ article: Article) -> UIAction {
let title = article.status.starred ?
NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") :
NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
let image = article.status.starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
let action = UIAction(title: title, image: image) { [weak self] action in
self?.coordinator.toggleStar(article)
}
return action
}
func markAboveAsReadAction(_ article: Article, indexPath: IndexPath) -> UIAction? {
guard coordinator.canMarkAboveAsRead(for: article), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let image = AppAssets.markAboveAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
self?.coordinator.markAboveAsRead(article)
}
}
return action
}
func markBelowAsReadAction(_ article: Article, indexPath: IndexPath) -> UIAction? {
guard coordinator.canMarkBelowAsRead(for: article), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let image = AppAssets.markBelowAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
self?.coordinator.markBelowAsRead(article)
}
}
return action
}
func markAboveAsReadAlertAction(_ article: Article, indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkAboveAsRead(for: article), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markAboveAsRead(article)
completion(true)
}
}
return action
}
func markBelowAsReadAlertAction(_ article: Article, indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkBelowAsRead(for: article), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markBelowAsRead(article)
completion(true)
}
}
return action
}
func discloseFeedAction(_ article: Article) -> UIAction? {
guard let webFeed = article.webFeed,
!coordinator.timelineFeedIsEqualTo(webFeed) else { return nil }
let title = NSLocalizedString("Go to Feed", comment: "Go to Feed")
let action = UIAction(title: title, image: AppAssets.openInSidebarImage) { [weak self] action in
self?.coordinator.discloseWebFeed(webFeed, animations: [.scroll, .navigation])
}
return action
}
func discloseFeedAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = article.webFeed,
!coordinator.timelineFeedIsEqualTo(webFeed) else { return nil }
let title = NSLocalizedString("Go to Feed", comment: "Go to Feed")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.discloseWebFeed(webFeed, animations: [.scroll, .navigation])
completion(true)
}
return action
}
func markAllInFeedAsReadAction(_ article: Article, indexPath: IndexPath) -> UIAction? {
guard let webFeed = article.webFeed else { return nil }
guard let fetchedArticles = try? webFeed.fetchArticles() else {
return nil
}
let articles = Array(fetchedArticles)
guard articles.canMarkAllAsRead(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
self?.coordinator.markAllAsRead(articles)
}
}
return action
}
func markAllInFeedAsReadAlertAction(_ article: Article, indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = article.webFeed else { return nil }
guard let fetchedArticles = try? webFeed.fetchArticles() else {
return nil
}
let articles = Array(fetchedArticles)
guard articles.canMarkAllAsRead(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Mark All as Read in Feed")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markAllAsRead(articles)
completion(true)
}
}
return action
}
func copyArticleURLAction(_ article: Article) -> UIAction? {
guard let url = article.preferredURL else { return nil }
let title = NSLocalizedString("Copy Article URL", comment: "Copy Article URL")
let action = UIAction(title: title, image: AppAssets.copyImage) { action in
UIPasteboard.general.url = url
}
return action
}
func copyExternalURLAction(_ article: Article) -> UIAction? {
guard let externalLink = article.externalLink, externalLink != article.preferredLink, let url = URL(string: externalLink) else { return nil }
let title = NSLocalizedString("Copy External URL", comment: "Copy External URL")
let action = UIAction(title: title, image: AppAssets.copyImage) { action in
UIPasteboard.general.url = url
}
return action
}
func openInBrowserAction(_ article: Article) -> UIAction? {
guard let _ = article.preferredURL else { return nil }
let title = NSLocalizedString("Open in Browser", comment: "Open in Browser")
let action = UIAction(title: title, image: AppAssets.safariImage) { [weak self] action in
self?.coordinator.showBrowserForArticle(article)
}
return action
}
func openInBrowserAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let _ = article.preferredURL else { return nil }
let title = NSLocalizedString("Open in Browser", comment: "Open in Browser")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.showBrowserForArticle(article)
completion(true)
}
return action
}
func shareDialogForTableCell(indexPath: IndexPath, url: URL, title: String?) {
let activityViewController = UIActivityViewController(url: url, title: title, applicationActivities: nil)
guard let cell = tableView.cellForRow(at: indexPath) else { return }
let popoverController = activityViewController.popoverPresentationController
popoverController?.sourceView = cell
popoverController?.sourceRect = CGRect(x: 0, y: 0, width: cell.frame.size.width, height: cell.frame.size.height)
present(activityViewController, animated: true)
}
func shareAction(_ article: Article, indexPath: IndexPath) -> UIAction? {
guard let url = article.preferredURL else { return nil }
let title = NSLocalizedString("Share", comment: "Share")
let action = UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
self?.shareDialogForTableCell(indexPath: indexPath, url: url, title: article.title)
}
return action
}
func shareAlertAction(_ article: Article, indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let url = article.preferredURL else { return nil }
let title = NSLocalizedString("Share", comment: "Share")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
completion(true)
self?.shareDialogForTableCell(indexPath: indexPath, url: url, title: article.title)
}
return action
}
}

View File

@@ -0,0 +1,83 @@
//
// UndoAvailableAlertController.swift
// NetNewsWire
//
// Created by Phil Viso on 9/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import UIKit
protocol MarkAsReadAlertControllerSourceType {}
extension CGRect: MarkAsReadAlertControllerSourceType {}
extension UIView: MarkAsReadAlertControllerSourceType {}
extension UIBarButtonItem: MarkAsReadAlertControllerSourceType {}
struct MarkAsReadAlertController {
static func confirm<T>(_ controller: UIViewController?,
coordinator: SceneCoordinator?,
confirmTitle: String,
sourceType: T,
cancelCompletion: (() -> Void)? = nil,
completion: @escaping () -> Void) where T: MarkAsReadAlertControllerSourceType {
guard let controller = controller, let coordinator = coordinator else {
completion()
return
}
if AppDefaults.shared.confirmMarkAllAsRead {
let alertController = MarkAsReadAlertController.alert(coordinator: coordinator, confirmTitle: confirmTitle, cancelCompletion: cancelCompletion, sourceType: sourceType) { _ in
completion()
}
controller.present(alertController, animated: true)
} else {
completion()
}
}
private static func alert<T>(coordinator: SceneCoordinator,
confirmTitle: String,
cancelCompletion: (() -> Void)?,
sourceType: T,
completion: @escaping (UIAlertAction) -> Void) -> UIAlertController where T: MarkAsReadAlertControllerSourceType {
let title = NSLocalizedString("Mark As Read", comment: "Mark As Read")
let message = NSLocalizedString("You can turn this confirmation off in Settings.",
comment: "You can turn this confirmation off in Settings.")
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
let settingsTitle = NSLocalizedString("Open Settings", comment: "Open Settings")
let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { _ in
cancelCompletion?()
}
let settingsAction = UIAlertAction(title: settingsTitle, style: .default) { _ in
coordinator.showSettings(scrollToArticlesSection: true)
}
let markAction = UIAlertAction(title: confirmTitle, style: .default, handler: completion)
alertController.addAction(markAction)
alertController.addAction(settingsAction)
alertController.addAction(cancelAction)
if let barButtonItem = sourceType as? UIBarButtonItem {
alertController.popoverPresentationController?.barButtonItem = barButtonItem
}
if let rect = sourceType as? CGRect {
alertController.popoverPresentationController?.sourceRect = rect
}
if let view = sourceType as? UIView {
alertController.popoverPresentationController?.sourceView = view
}
return alertController
}
}