mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Rename Master* to Main*.
This commit is contained in:
@@ -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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
85
iOS/MainTimeline/Cell/MainTimelineCellData.swift
Normal file
85
iOS/MainTimeline/Cell/MainTimelineCellData.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
113
iOS/MainTimeline/Cell/MainTimelineCellLayout.swift
Normal file
113
iOS/MainTimeline/Cell/MainTimelineCellLayout.swift
Normal 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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
126
iOS/MainTimeline/Cell/MainTimelineDefaultCellLayout.swift
Normal file
126
iOS/MainTimeline/Cell/MainTimelineDefaultCellLayout.swift
Normal 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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
310
iOS/MainTimeline/Cell/MainTimelineTableViewCell.swift
Normal file
310
iOS/MainTimeline/Cell/MainTimelineTableViewCell.swift
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
19
iOS/MainTimeline/Cell/MainUnreadIndicatorView.swift
Normal file
19
iOS/MainTimeline/Cell/MainUnreadIndicatorView.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
182
iOS/MainTimeline/Cell/MultilineUILabelSizer.swift
Normal file
182
iOS/MainTimeline/Cell/MultilineUILabelSizer.swift
Normal 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 {
|
||||
|
||||
// We’ll 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 neighbor’s height is single line height, then this wider width must also be single-line height.
|
||||
// If a wider neighbor’s 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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
64
iOS/MainTimeline/Cell/SingleLineUILabelSizer.swift
Normal file
64
iOS/MainTimeline/Cell/SingleLineUILabelSizer.swift
Normal 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. It’s easiest.
|
||||
|
||||
static func size(for text: String, font: UIFont) -> CGSize {
|
||||
return sizer(for: font).size(for: text)
|
||||
}
|
||||
|
||||
static func emptyCache() {
|
||||
sizers = [UIFont: SingleLineUILabelSizer]()
|
||||
}
|
||||
|
||||
}
|
||||
18
iOS/MainTimeline/MainTimelineDataSource.swift
Normal file
18
iOS/MainTimeline/MainTimelineDataSource.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
64
iOS/MainTimeline/MainTimelineTitleView.swift
Normal file
64
iOS/MainTimeline/MainTimelineTitleView.swift
Normal 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))
|
||||
}
|
||||
|
||||
}
|
||||
58
iOS/MainTimeline/MainTimelineTitleView.xib
Normal file
58
iOS/MainTimeline/MainTimelineTitleView.xib
Normal 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>
|
||||
39
iOS/MainTimeline/MainTimelineUnreadCountView.swift
Normal file
39
iOS/MainTimeline/MainTimelineUnreadCountView.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
984
iOS/MainTimeline/MainTimelineViewController.swift
Normal file
984
iOS/MainTimeline/MainTimelineViewController.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
83
iOS/MainTimeline/MarkAsReadAlertController.swift
Normal file
83
iOS/MainTimeline/MarkAsReadAlertController.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user