Remove Master from names.

This commit is contained in:
Brent Simmons
2024-02-26 08:37:15 -08:00
parent 5b34217374
commit fea6d03bc3
42 changed files with 588 additions and 590 deletions

View File

@@ -0,0 +1,23 @@
//
// FeedRowIdentifier.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
class FeedRowIdentifier: NSObject, NSCopying {
var indexPath: IndexPath
init(indexPath: IndexPath) {
self.indexPath = indexPath
}
func copy(with zone: NSZone? = nil) -> Any {
return self
}
}

View File

@@ -0,0 +1,232 @@
//
// TableViewCell.swift
// NetNewsWire
//
// Created by Brent Simmons on 8/1/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
import Account
import RSTree
protocol FeedTableViewCellDelegate: AnyObject {
func feedTableViewCellDisclosureDidToggle(_ sender: FeedTableViewCell, expanding: Bool)
}
class FeedTableViewCell : VibrantTableViewCell {
weak var delegate: FeedTableViewCellDelegate?
override var accessibilityLabel: String? {
set {}
get {
if unreadCount > 0 {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessiblity")
return "\(name) \(unreadCount) \(unreadLabel)"
} else {
return name
}
}
}
var iconImage: IconImage? {
didSet {
iconView.iconImage = iconImage
}
}
var isDisclosureAvailable = false {
didSet {
if isDisclosureAvailable != oldValue {
setNeedsLayout()
}
}
}
var isSeparatorShown = true {
didSet {
if isSeparatorShown != oldValue {
if isSeparatorShown {
showView(bottomSeparatorView)
} else {
hideView(bottomSeparatorView)
}
}
}
}
var unreadCount: Int {
get {
return unreadCountView.unreadCount
}
set {
if unreadCountView.unreadCount != newValue {
unreadCountView.unreadCount = newValue
unreadCountView.isHidden = (newValue < 1)
setNeedsLayout()
}
}
}
var name: String {
get {
return titleView.text ?? ""
}
set {
if titleView.text != newValue {
titleView.text = newValue
setNeedsLayout()
}
}
}
private let titleView: UILabel = {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
label.lineBreakMode = .byTruncatingTail
label.font = .preferredFont(forTextStyle: .body)
return label
}()
private let iconView = IconView()
private let bottomSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
view.alpha = 0.5
return view
}()
private var isDisclosureExpanded = false
private var disclosureButton: UIButton?
private var unreadCountView = FeedUnreadCountView(frame: CGRect.zero)
private var isShowingEditControl = false
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func setDisclosure(isExpanded: Bool, animated: Bool) {
isDisclosureExpanded = isExpanded
let duration = animated ? 0.3 : 0.0
UIView.animate(withDuration: duration) {
if self.isDisclosureExpanded {
self.disclosureButton?.accessibilityLabel = NSLocalizedString("Collapse Folder", comment: "Collapse Folder")
self.disclosureButton?.imageView?.transform = CGAffineTransform(rotationAngle: 1.570796)
} else {
self.disclosureButton?.accessibilityLabel = NSLocalizedString("Expand Folder", comment: "Expand Folder")
self.disclosureButton?.imageView?.transform = CGAffineTransform(rotationAngle: 0)
}
}
}
override func applyThemeProperties() {
super.applyThemeProperties()
}
override func willTransition(to state: UITableViewCell.StateMask) {
super.willTransition(to: state)
isShowingEditControl = state.contains(.showingEditControl)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = FeedTableViewCellLayout(cellWidth: bounds.size.width, insets: safeAreaInsets, label: titleView, unreadCountView: unreadCountView, showingEditingControl: isShowingEditControl, indent: indentationLevel == 1, shouldShowDisclosure: isDisclosureAvailable)
return CGSize(width: bounds.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = FeedTableViewCellLayout(cellWidth: bounds.size.width, insets: safeAreaInsets, label: titleView, unreadCountView: unreadCountView, showingEditingControl: isShowingEditControl, indent: indentationLevel == 1, shouldShowDisclosure: isDisclosureAvailable)
layoutWith(layout)
}
@objc func buttonPressed(_ sender: UIButton) {
if isDisclosureAvailable {
setDisclosure(isExpanded: !isDisclosureExpanded, animated: true)
delegate?.feedTableViewCellDisclosureDidToggle(self, expanding: isDisclosureExpanded)
}
}
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
let iconTintColor: UIColor
if isHighlighted || isSelected {
iconTintColor = AppAssets.vibrantTextColor
} else {
if let preferredColor = iconImage?.preferredColor {
iconTintColor = UIColor(cgColor: preferredColor)
} else {
iconTintColor = AppAssets.secondaryAccentColor
}
}
if animated {
UIView.animate(withDuration: Self.duration) {
self.iconView.tintColor = iconTintColor
}
} else {
self.iconView.tintColor = iconTintColor
}
updateLabelVibrancy(titleView, color: labelColor, animated: animated)
}
}
private extension FeedTableViewCell {
func commonInit() {
addSubviewAtInit(unreadCountView)
addSubviewAtInit(iconView)
addSubviewAtInit(titleView)
addDisclosureView()
addSubviewAtInit(bottomSeparatorView)
}
func addDisclosureView() {
disclosureButton = NonIntrinsicButton(type: .roundedRect)
disclosureButton!.addTarget(self, action: #selector(buttonPressed(_:)), for: UIControl.Event.touchUpInside)
disclosureButton?.setImage(AppAssets.disclosureImage, for: .normal)
disclosureButton?.tintColor = AppAssets.controlBackgroundColor
disclosureButton?.imageView?.contentMode = .center
disclosureButton?.imageView?.clipsToBounds = false
if #available(iOS 13.4, *) {
disclosureButton?.addInteraction(UIPointerInteraction())
}
addSubviewAtInit(disclosureButton!)
}
func addSubviewAtInit(_ view: UIView) {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
}
func layoutWith(_ layout: FeedTableViewCellLayout) {
iconView.setFrameIfNotEqual(layout.faviconRect)
titleView.setFrameIfNotEqual(layout.titleRect)
unreadCountView.setFrameIfNotEqual(layout.unreadCountRect)
disclosureButton?.setFrameIfNotEqual(layout.disclosureButtonRect)
disclosureButton?.isHidden = !isDisclosureAvailable
bottomSeparatorView.setFrameIfNotEqual(layout.separatorRect)
}
func hideView(_ view: UIView) {
if !view.isHidden {
view.isHidden = true
}
}
func showView(_ view: UIView) {
if view.isHidden {
view.isHidden = false
}
}
}

View File

@@ -0,0 +1,148 @@
//
// TableViewCellLayout.swift
// NetNewsWire
//
// Created by Brent Simmons on 11/24/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct FeedTableViewCellLayout {
private static let indentWidth = CGFloat(integerLiteral: 42)
private static let editingControlIndent = CGFloat(integerLiteral: 40)
private static let imageSize = CGSize(width: 24, height: 24)
private static let imageMarginRight = CGFloat(integerLiteral: 11)
private static let labelMarginRight = CGFloat(integerLiteral: 8)
private static let unreadCountMarginRight = CGFloat(integerLiteral: 16)
private static let disclosureButtonSize = CGSize(width: 44, height: 44)
private static let verticalPadding = CGFloat(integerLiteral: 11)
private static let minRowHeight = CGFloat(integerLiteral: 44)
static let faviconCornerRadius = CGFloat(integerLiteral: 2)
let faviconRect: CGRect
let titleRect: CGRect
let unreadCountRect: CGRect
let disclosureButtonRect: CGRect
let separatorRect: CGRect
let height: CGFloat
init(cellWidth: CGFloat, insets: UIEdgeInsets, label: UILabel, unreadCountView: FeedUnreadCountView, showingEditingControl: Bool, indent: Bool, shouldShowDisclosure: Bool) {
var initialIndent = insets.left
if indent {
initialIndent += FeedTableViewCellLayout.indentWidth
}
let bounds = CGRect(x: initialIndent, y: 0.0, width: floor(cellWidth - initialIndent - insets.right), height: 0.0)
// Disclosure Button
var rDisclosure = CGRect.zero
if shouldShowDisclosure {
rDisclosure.size = FeedTableViewCellLayout.disclosureButtonSize
rDisclosure.origin.x = bounds.origin.x
}
// Favicon
var rFavicon = CGRect.zero
if !shouldShowDisclosure {
let x = bounds.origin.x
let y = UIFontMetrics.default.scaledValue(for: FeedTableViewCellLayout.verticalPadding) +
label.font.lineHeight / 2.0 -
FeedTableViewCellLayout.imageSize.height / 2.0
rFavicon = CGRect(x: x, y: y, width: FeedTableViewCellLayout.imageSize.width, height: FeedTableViewCellLayout.imageSize.height)
}
// Unread Count
let unreadCountSize = unreadCountView.contentSize
let unreadCountIsHidden = unreadCountView.unreadCount < 1
var rUnread = CGRect.zero
if !unreadCountIsHidden {
rUnread.size = unreadCountSize
rUnread.origin.x = bounds.maxX - (FeedTableViewCellLayout.unreadCountMarginRight + unreadCountSize.width)
}
// Title
var rLabelx = insets.left + FeedTableViewCellLayout.disclosureButtonSize.width
if !shouldShowDisclosure {
rLabelx = rLabelx + FeedTableViewCellLayout.imageSize.width + FeedTableViewCellLayout.imageMarginRight
}
let rLabely = UIFontMetrics.default.scaledValue(for: FeedTableViewCellLayout.verticalPadding)
var labelWidth = CGFloat.zero
if !unreadCountIsHidden {
labelWidth = cellWidth - (rLabelx + FeedTableViewCellLayout.labelMarginRight + (cellWidth - rUnread.minX))
} else {
labelWidth = cellWidth - (rLabelx + FeedTableViewCellLayout.labelMarginRight)
}
let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth)))
// Now that we've got everything (especially the label) computed without the editing controls, update for them.
// We do this because we don't want the row height to change when the editing controls are brought out. We will
// handle the missing space, but removing it from the label and truncating.
if showingEditingControl {
rDisclosure.origin.x += FeedTableViewCellLayout.editingControlIndent
rFavicon.origin.x += FeedTableViewCellLayout.editingControlIndent
rLabelx += FeedTableViewCellLayout.editingControlIndent
if !unreadCountIsHidden {
rUnread.origin.x -= FeedTableViewCellLayout.editingControlIndent
labelWidth = cellWidth - (rLabelx + FeedTableViewCellLayout.labelMarginRight + (cellWidth - rUnread.minX))
} else {
labelWidth = cellWidth - (rLabelx + FeedTableViewCellLayout.labelMarginRight + FeedTableViewCellLayout.editingControlIndent)
}
}
var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height)
// Determine cell height
let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: FeedTableViewCellLayout.verticalPadding)
let maxGraphicsHeight = [rFavicon, rUnread, rDisclosure].maxY()
var cellHeight = max(paddedLabelHeight, maxGraphicsHeight)
if cellHeight < FeedTableViewCellLayout.minRowHeight {
cellHeight = FeedTableViewCellLayout.minRowHeight
}
// Center in Cell
let newBounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.width, height: cellHeight)
if !unreadCountIsHidden {
rUnread = FeedTableViewCellLayout.centerVertically(rUnread, newBounds)
}
if shouldShowDisclosure {
rDisclosure = FeedTableViewCellLayout.centerVertically(rDisclosure, newBounds)
}
// Small fonts and the Favicon need centered if we hit the minimum row height
if cellHeight == FeedTableViewCellLayout.minRowHeight {
rLabel = FeedTableViewCellLayout.centerVertically(rLabel, newBounds)
rFavicon = FeedTableViewCellLayout.centerVertically(rFavicon, newBounds)
}
// Separator Insets
let separatorInset = FeedTableViewCellLayout.disclosureButtonSize.width
separatorRect = CGRect(x: separatorInset, y: cellHeight - 0.5, width: cellWidth - separatorInset, height: 0.5)
// Assign the properties
self.height = cellHeight
self.faviconRect = rFavicon
self.unreadCountRect = rUnread
self.disclosureButtonRect = rDisclosure
self.titleRect = rLabel
}
// Ideally this will be implemented in RSCore (see RSGeometry)
static func centerVertically(_ originalRect: CGRect, _ containerRect: CGRect) -> CGRect {
var result = originalRect
result.origin.y = containerRect.midY - (result.height / 2.0)
result = result.integral
result.size = originalRect.size
return result
}
}

View File

@@ -0,0 +1,208 @@
//
// FeedTableViewSectionHeader.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/18/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
protocol FeedTableViewSectionHeaderDelegate {
func FeedTableViewSectionHeaderDisclosureDidToggle(_ sender: FeedTableViewSectionHeader)
}
class FeedTableViewSectionHeader: UITableViewHeaderFooterView {
var delegate: FeedTableViewSectionHeaderDelegate?
override var accessibilityLabel: String? {
set {}
get {
if unreadCount > 0 {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessiblity")
return "\(name) \(unreadCount) \(unreadLabel) \(expandedStateMessage) "
} else {
return "\(name) \(expandedStateMessage) "
}
}
}
private var expandedStateMessage: String {
set {}
get {
if disclosureExpanded {
return NSLocalizedString("Expanded", comment: "Disclosure button expanded state for accessibility")
}
return NSLocalizedString("Collapsed", comment: "Disclosure button collapsed state for accessibility")
}
}
var unreadCount: Int {
get {
return unreadCountView.unreadCount
}
set {
if unreadCountView.unreadCount != newValue {
unreadCountView.unreadCount = newValue
updateUnreadCountView()
setNeedsLayout()
}
}
}
var name: String {
get {
return titleView.text ?? ""
}
set {
if titleView.text != newValue {
titleView.text = newValue
setNeedsLayout()
}
}
}
var disclosureExpanded = false {
didSet {
updateExpandedState(animate: true)
updateUnreadCountView()
}
}
var isLastSection = false
private let titleView: UILabel = {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
label.font = .preferredFont(forTextStyle: .body)
return label
}()
private let unreadCountView = FeedUnreadCountView(frame: CGRect.zero)
private lazy var disclosureButton: UIButton = {
let button = NonIntrinsicButton()
button.tintColor = UIColor.tertiaryLabel
button.setImage(AppAssets.disclosureImage, for: .normal)
button.contentMode = .center
if #available(iOS 13.4, *) {
button.addInteraction(UIPointerInteraction())
}
button.addTarget(self, action: #selector(toggleDisclosure), for: .touchUpInside)
return button
}()
private let topSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
return view
}()
private let bottomSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
return view
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = FeedTableViewSectionHeaderLayout(cellWidth: size.width, insets: safeAreaInsets, label: titleView, unreadCountView: unreadCountView)
return CGSize(width: bounds.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = FeedTableViewSectionHeaderLayout(cellWidth: contentView.bounds.size.width,
insets: contentView.safeAreaInsets,
label: titleView,
unreadCountView: unreadCountView)
layoutWith(layout)
}
}
private extension FeedTableViewSectionHeader {
@objc func toggleDisclosure() {
delegate?.FeedTableViewSectionHeaderDisclosureDidToggle(self)
}
func commonInit() {
addSubviewAtInit(unreadCountView)
addSubviewAtInit(titleView)
addSubviewAtInit(disclosureButton)
updateExpandedState(animate: false)
addBackgroundView()
addSubviewAtInit(topSeparatorView)
addSubviewAtInit(bottomSeparatorView)
}
func updateExpandedState(animate: Bool) {
if !isLastSection && self.disclosureExpanded {
self.bottomSeparatorView.isHidden = false
}
let duration = animate ? 0.3 : 0.0
UIView.animate(
withDuration: duration,
animations: {
if self.disclosureExpanded {
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 1.570796)
} else {
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 0)
}
}, completion: { _ in
if !self.isLastSection && !self.disclosureExpanded {
self.bottomSeparatorView.isHidden = true
}
})
}
func updateUnreadCountView() {
if !disclosureExpanded && unreadCount > 0 {
UIView.animate(withDuration: 0.3) {
self.unreadCountView.alpha = 1
}
} else {
UIView.animate(withDuration: 0.3) {
self.unreadCountView.alpha = 0
}
}
}
func addSubviewAtInit(_ view: UIView) {
contentView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
}
func layoutWith(_ layout: FeedTableViewSectionHeaderLayout) {
titleView.setFrameIfNotEqual(layout.titleRect)
unreadCountView.setFrameIfNotEqual(layout.unreadCountRect)
disclosureButton.setFrameIfNotEqual(layout.disclosureButtonRect)
let top = CGRect(x: safeAreaInsets.left, y: 0, width: frame.width - safeAreaInsets.right - safeAreaInsets.left, height: 0.33)
topSeparatorView.setFrameIfNotEqual(top)
let bottom = CGRect(x: safeAreaInsets.left, y: frame.height - 0.33, width: frame.width - safeAreaInsets.right - safeAreaInsets.left, height: 0.33)
bottomSeparatorView.setFrameIfNotEqual(bottom)
}
func addBackgroundView() {
self.backgroundView = UIView(frame: self.bounds)
self.backgroundView?.backgroundColor = AppAssets.sectionHeaderColor
}
}

View File

@@ -0,0 +1,90 @@
//
// FeedTableViewSectionHeaderLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/5/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct FeedTableViewSectionHeaderLayout {
private static let labelMarginRight = CGFloat(integerLiteral: 8)
private static let unreadCountMarginRight = CGFloat(integerLiteral: 16)
private static let disclosureButtonSize = CGSize(width: 44, height: 44)
private static let verticalPadding = CGFloat(integerLiteral: 11)
private static let minRowHeight = CGFloat(integerLiteral: 44)
let titleRect: CGRect
let unreadCountRect: CGRect
let disclosureButtonRect: CGRect
let height: CGFloat
init(cellWidth: CGFloat, insets: UIEdgeInsets, label: UILabel, unreadCountView: FeedUnreadCountView) {
let bounds = CGRect(x: insets.left, y: 0.0, width: floor(cellWidth - insets.right), height: 0.0)
// Disclosure Button
var rDisclosure = CGRect.zero
rDisclosure.size = FeedTableViewSectionHeaderLayout.disclosureButtonSize
rDisclosure.origin.x = bounds.origin.x
// Unread Count
let unreadCountSize = unreadCountView.contentSize
let unreadCountIsHidden = unreadCountView.unreadCount < 1
var rUnread = CGRect.zero
if !unreadCountIsHidden {
rUnread.size = unreadCountSize
rUnread.origin.x = bounds.maxX - (FeedTableViewSectionHeaderLayout.unreadCountMarginRight + unreadCountSize.width)
}
// Max Unread Count
// We can't reload Section Headers so we don't let the title extend into the (probably) worse case Unread Count area.
let maxUnreadCountView = FeedUnreadCountView(frame: CGRect.zero)
maxUnreadCountView.unreadCount = 888
let maxUnreadCountSize = maxUnreadCountView.contentSize
// Title
let rLabelx = insets.left + FeedTableViewSectionHeaderLayout.disclosureButtonSize.width
let rLabely = UIFontMetrics.default.scaledValue(for: FeedTableViewSectionHeaderLayout.verticalPadding)
var labelWidth = CGFloat.zero
labelWidth = cellWidth - (rLabelx + FeedTableViewSectionHeaderLayout.labelMarginRight + maxUnreadCountSize.width + FeedTableViewSectionHeaderLayout.unreadCountMarginRight)
let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth)))
var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height)
// Determine cell height
let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: FeedTableViewSectionHeaderLayout.verticalPadding)
let maxGraphicsHeight = [rUnread, rDisclosure].maxY()
var cellHeight = max(paddedLabelHeight, maxGraphicsHeight)
if cellHeight < FeedTableViewSectionHeaderLayout.minRowHeight {
cellHeight = FeedTableViewSectionHeaderLayout.minRowHeight
}
// Center in Cell
let newBounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.width, height: cellHeight)
if !unreadCountIsHidden {
rUnread = FeedTableViewCellLayout.centerVertically(rUnread, newBounds)
}
rDisclosure = FeedTableViewCellLayout.centerVertically(rDisclosure, newBounds)
// Small fonts need centered if we hit the minimum row height
if cellHeight == FeedTableViewSectionHeaderLayout.minRowHeight {
rLabel = FeedTableViewCellLayout.centerVertically(rLabel, newBounds)
}
// Assign the properties
self.height = cellHeight
self.unreadCountRect = rUnread
self.disclosureButtonRect = rDisclosure
self.titleRect = rLabel
}
}

View File

@@ -0,0 +1,123 @@
//
// UnreadCountView.swift
// NetNewsWire
//
// Created by Brent Simmons on 11/22/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
class FeedUnreadCountView : UIView {
var padding: UIEdgeInsets {
return UIEdgeInsets(top: 1.0, left: 9.0, bottom: 1.0, right: 9.0)
}
let cornerRadius = 8.0
let bgColor = AppAssets.controlBackgroundColor
var textColor: UIColor {
return UIColor.white
}
var textAttributes: [NSAttributedString.Key: AnyObject] {
let textFont = UIFont.preferredFont(forTextStyle: .caption1).bold()
return [NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: textFont, NSAttributedString.Key.kern: NSNull()]
}
var textSizeCache = [Int: CGSize]()
var unreadCount = 0 {
didSet {
contentSizeIsValid = false
invalidateIntrinsicContentSize()
setNeedsDisplay()
}
}
var unreadCountString: String {
return unreadCount < 1 ? "" : "\(unreadCount)"
}
private var contentSizeIsValid = false
private var _contentSize = CGSize.zero
override init(frame: CGRect) {
super.init(frame: frame)
self.isOpaque = false
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.isOpaque = false
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
textSizeCache = [Int: CGSize]()
contentSizeIsValid = false
setNeedsDisplay()
}
var contentSize: CGSize {
if !contentSizeIsValid {
var size = CGSize.zero
if unreadCount > 0 {
size = textSize()
size.width += (padding.left + padding.right)
size.height += (padding.top + padding.bottom)
}
_contentSize = size
contentSizeIsValid = true
}
return _contentSize
}
// Prevent autolayout from messing around with our frame settings
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
func textSize() -> CGSize {
if unreadCount < 1 {
return CGSize.zero
}
if let cachedSize = textSizeCache[unreadCount] {
return cachedSize
}
var size = unreadCountString.size(withAttributes: textAttributes)
size.height = ceil(size.height)
size.width = ceil(size.width)
textSizeCache[unreadCount] = size
return size
}
func textRect() -> CGRect {
let size = textSize()
var r = CGRect.zero
r.size = size
r.origin.x = (bounds.maxX - padding.right) - r.size.width
r.origin.y = padding.top
return r
}
override func draw(_ dirtyRect: CGRect) {
let cornerRadii = CGSize(width: cornerRadius, height: cornerRadius)
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: .allCorners, cornerRadii: cornerRadii)
bgColor.setFill()
path.fill()
if unreadCount > 0 {
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
}
}
}

View File

@@ -0,0 +1,18 @@
//
// MasterFeedTableViewCellSectionIdentifier.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 6/3/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import RSTree
struct MasterFeedTableViewSectionIdentifier: Hashable {
init(node: Node) {
}
}

View File

@@ -0,0 +1,78 @@
//
// MasterFeedTableViewIdentifier.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 6/3/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import RSTree
final class MasterFeedTableViewIdentifier: NSObject, NSCopying {
let feedID: FeedIdentifier?
let containerID: ContainerIdentifier?
let parentContainerID: ContainerIdentifier?
let isEditable: Bool
let isPsuedoFeed: Bool
let isFolder: Bool
let isWebFeed: Bool
let nameForDisplay: String
let url: String?
let unreadCount: Int
let childCount: Int
var account: Account? {
if isFolder, let parentContainerID = parentContainerID {
return AccountManager.shared.existingContainer(with: parentContainerID) as? Account
}
if isWebFeed, let feedID = feedID {
return (AccountManager.shared.existingFeed(with: feedID) as? WebFeed)?.account
}
return nil
}
init(node: Node, unreadCount: Int) {
let feed = node.representedObject as! Feed
self.feedID = feed.feedID
self.containerID = (node.representedObject as? Container)?.containerID
self.parentContainerID = (node.parent?.representedObject as? Container)?.containerID
self.isEditable = !(node.representedObject is PseudoFeed)
self.isPsuedoFeed = node.representedObject is PseudoFeed
self.isFolder = node.representedObject is Folder
self.isWebFeed = node.representedObject is WebFeed
self.nameForDisplay = feed.nameForDisplay
if let webFeed = node.representedObject as? WebFeed {
self.url = webFeed.url
} else {
self.url = nil
}
self.unreadCount = unreadCount
self.childCount = node.numberOfChildNodes
}
override func isEqual(_ object: Any?) -> Bool {
guard let otherIdentifier = object as? MasterFeedTableViewIdentifier else { return false }
if self === otherIdentifier { return true }
return feedID == otherIdentifier.feedID && parentContainerID == otherIdentifier.parentContainerID
}
override var hash: Int {
var hasher = Hasher()
hasher.combine(feedID)
hasher.combine(parentContainerID)
return hasher.finalize()
}
func copy(with zone: NSZone? = nil) -> Any {
return self
}
}

View File

@@ -0,0 +1,34 @@
//
// FeedViewController+Drag.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import MobileCoreServices
import Account
import UniformTypeIdentifiers
extension FeedsViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let node = coordinator.nodeFor(indexPath), let feed = node.representedObject as? Feed else {
return [UIDragItem]()
}
let data = feed.url.data(using: .utf8)
let itemProvider = NSItemProvider()
itemProvider.registerDataRepresentation(forTypeIdentifier: UTType.url.identifier, visibility: .ownProcess) { completion in
completion(data, nil)
return nil
}
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = node
return [dragItem]
}
}

View File

@@ -0,0 +1,166 @@
//
// FeedViewController+Drop.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import Account
import RSTree
extension FeedsViewController: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
return session.localDragSession != nil
}
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
guard let destIndexPath = destinationIndexPath, destIndexPath.section > 0, tableView.hasActiveDrag else {
return UITableViewDropProposal(operation: .forbidden)
}
guard let destFeed = coordinator.nodeFor(destIndexPath)?.representedObject as? SidebarItem,
let destAccount = destFeed.account,
let destCell = tableView.cellForRow(at: destIndexPath) else {
return UITableViewDropProposal(operation: .forbidden)
}
// Validate account specific behaviors...
if destAccount.behaviors.contains(.disallowFeedInMultipleFolders),
let sourceNode = session.localDragSession?.items.first?.localObject as? Node,
let sourceFeed = sourceNode.representedObject as? Feed,
sourceFeed.account?.accountID != destAccount.accountID && destAccount.hasFeed(withURL: sourceFeed.url) {
return UITableViewDropProposal(operation: .forbidden)
}
// Determine the correct drop proposal
if destFeed is Folder {
if session.location(in: destCell).y >= 0 {
return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
} else {
return UITableViewDropProposal(operation: .move, intent: .unspecified)
}
} else {
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
}
func tableView(_ tableView: UITableView, performDropWith dropCoordinator: UITableViewDropCoordinator) {
guard let dragItem = dropCoordinator.items.first?.dragItem,
let dragNode = dragItem.localObject as? Node,
let source = dragNode.parent?.representedObject as? Container,
let destIndexPath = dropCoordinator.destinationIndexPath else {
return
}
let isFolderDrop: Bool = {
if coordinator.nodeFor(destIndexPath)?.representedObject is Folder, let propCell = tableView.cellForRow(at: destIndexPath) {
return dropCoordinator.session.location(in: propCell).y >= 0
}
return false
}()
// Based on the drop we have to determine a node to start looking for a parent container.
let destNode: Node? = {
if isFolderDrop {
return coordinator.nodeFor(destIndexPath)
} else {
if destIndexPath.row == 0 {
return coordinator.nodeFor(IndexPath(row: 0, section: destIndexPath.section))
} else if destIndexPath.row > 0 {
return coordinator.nodeFor(IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section))
} else {
return nil
}
}
}()
// Now we start looking for the parent container
let destinationContainer: Container? = {
if let container = (destNode?.representedObject as? Container) ?? (destNode?.parent?.representedObject as? Container) {
return container
} else {
// If we got here, we are trying to drop on an empty section header. Go and find the Account for this section
return coordinator.rootNode.childAtIndex(destIndexPath.section)?.representedObject as? Account
}
}()
guard let destination = destinationContainer, let feed = dragNode.representedObject as? Feed else { return }
if source.account == destination.account {
moveFeedInAccount(feed: feed, sourceContainer: source, destinationContainer: destination)
} else {
moveFeedBetweenAccounts(feed: feed, sourceContainer: source, destinationContainer: destination)
}
}
func moveFeedInAccount(feed: Feed, sourceContainer: Container, destinationContainer: Container) {
guard sourceContainer !== destinationContainer else { return }
BatchUpdate.shared.start()
sourceContainer.account?.moveFeed(feed, from: sourceContainer, to: destinationContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
}
func moveFeedBetweenAccounts(feed: Feed, sourceContainer: Container, destinationContainer: Container) {
if let existingFeed = destinationContainer.account?.existingFeed(withURL: feed.url) {
BatchUpdate.shared.start()
destinationContainer.account?.addFeed(existingFeed, to: destinationContainer) { result in
switch result {
case .success:
sourceContainer.account?.removeFeed(feed, from: sourceContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
case .failure(let error):
BatchUpdate.shared.end()
self.presentError(error)
}
}
} else {
BatchUpdate.shared.start()
destinationContainer.account?.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer, validateFeed: false) { result in
switch result {
case .success:
sourceContainer.account?.removeFeed(feed, from: sourceContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
case .failure(let error):
BatchUpdate.shared.end()
self.presentError(error)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
//
// RefeshProgressView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/24/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import Account
class RefreshProgressView: UIView {
@IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var label: UILabel!
override func awakeFromNib() {
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
update()
scheduleUpdateRefreshLabel()
isAccessibilityElement = true
accessibilityTraits = [.updatesFrequently, .notEnabled]
}
func update() {
if !AccountManager.shared.combinedRefreshProgress.isComplete {
progressChanged(animated: false)
} else {
updateRefreshLabel()
}
}
override func didMoveToSuperview() {
progressChanged(animated: false)
}
@objc func progressDidChange(_ note: Notification) {
progressChanged(animated: true)
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
// This hack is probably necessary because custom views in the toolbar don't get
// notifications that the content size changed.
label.font = UIFont.preferredFont(forTextStyle: .footnote)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
// MARK: Private
private extension RefreshProgressView {
func progressChanged(animated: Bool) {
// Layout may crash if not in the view hierarchy.
// https://github.com/Ranchero-Software/NetNewsWire/issues/1764
let isInViewHierarchy = self.superview != nil
let progress = AccountManager.shared.combinedRefreshProgress
if progress.isComplete {
if isInViewHierarchy {
progressView.setProgress(1, animated: animated)
}
func completeLabel() {
// Check that there are no pending downloads.
if AccountManager.shared.combinedRefreshProgress.isComplete {
self.updateRefreshLabel()
self.label.isHidden = false
self.progressView.isHidden = true
if self.superview != nil {
self.progressView.setProgress(0, animated: animated)
}
}
}
if animated {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
completeLabel()
}
} else {
completeLabel()
}
} else {
label.isHidden = true
progressView.isHidden = false
if isInViewHierarchy {
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
// Don't let the progress bar go backwards unless we need to go back more than 25%
if percent > progressView.progress || progressView.progress - percent > 0.25 {
progressView.setProgress(percent, animated: animated)
}
}
}
}
func updateRefreshLabel() {
if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime {
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(60) {
let relativeDateTimeFormatter = RelativeDateTimeFormatter()
relativeDateTimeFormatter.dateTimeStyle = .named
let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date())
let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated")
let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String
label.text = refreshText
} else {
label.text = NSLocalizedString("Updated Just Now", comment: "Updated Just Now")
}
} else {
label.text = ""
}
accessibilityLabel = label.text
}
func scheduleUpdateRefreshLabel() {
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
self?.updateRefreshLabel()
self?.scheduleUpdateRefreshLabel()
}
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17156" 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="17125"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="ejl-zC-eNy" customClass="RefreshProgressView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="461" height="90"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" progress="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Ds3-59-ooT" customClass="RoundedProgressView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="180.5" y="42.5" width="100" height="5"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="ReS-sT-7EN"/>
<constraint firstAttribute="height" constant="5" id="oDX-bb-24H"/>
</constraints>
</progressView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7mJ-VZ-zqU">
<rect key="frame" x="214" y="34" width="33" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="sNo-8i-tO3"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Ds3-59-ooT" firstAttribute="centerX" secondItem="ejl-zC-eNy" secondAttribute="centerX" id="5Rv-6l-HSL"/>
<constraint firstItem="Ds3-59-ooT" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ejl-zC-eNy" secondAttribute="leading" id="Bck-uf-0G7"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="bottom" secondItem="sNo-8i-tO3" secondAttribute="bottom" id="DVn-hI-PhH"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="sNo-8i-tO3" secondAttribute="leading" id="Sbp-yf-ts9"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="centerY" secondItem="ejl-zC-eNy" secondAttribute="centerY" id="Shb-X2-Fwc"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="centerX" secondItem="ejl-zC-eNy" secondAttribute="centerX" id="lFg-fm-YmV"/>
<constraint firstItem="sNo-8i-tO3" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="7mJ-VZ-zqU" secondAttribute="trailing" id="mZ2-XG-Kvg"/>
<constraint firstItem="Ds3-59-ooT" firstAttribute="centerY" secondItem="ejl-zC-eNy" secondAttribute="centerY" id="tIh-lb-KbY"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Ds3-59-ooT" secondAttribute="trailing" id="vSU-N6-Sk5"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="label" destination="7mJ-VZ-zqU" id="MHr-r4-qop"/>
<outlet property="progressView" destination="Ds3-59-ooT" id="TjM-db-LxM"/>
</connections>
<point key="canvasLocation" x="-75" y="-117"/>
</view>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@@ -0,0 +1,70 @@
//
// ShadowTableChanges.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
struct ShadowTableChanges {
struct Move: Hashable {
var from: Int
var to: Int
init(_ from: Int, _ to: Int) {
self.from = from
self.to = to
}
}
struct RowChanges {
var section: Int
var deletes: Set<Int>?
var inserts: Set<Int>?
var moves: Set<ShadowTableChanges.Move>?
var isEmpty: Bool {
return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true)
}
var deleteIndexPaths: [IndexPath]? {
guard let deletes = deletes else { return nil }
return deletes.map { IndexPath(row: $0, section: section) }
}
var insertIndexPaths: [IndexPath]? {
guard let inserts = inserts else { return nil }
return inserts.map { IndexPath(row: $0, section: section) }
}
var moveIndexPaths: [(IndexPath, IndexPath)]? {
guard let moves = moves else { return nil }
return moves.map { (IndexPath(row: $0.from, section: section), IndexPath(row: $0.to, section: section)) }
}
init(section: Int, deletes: Set<Int>?, inserts: Set<Int>?, moves: Set<Move>?) {
self.section = section
self.deletes = deletes
self.inserts = inserts
self.moves = moves
}
}
var deletes: Set<Int>?
var inserts: Set<Int>?
var moves: Set<Move>?
var rowChanges: [RowChanges]?
init(deletes: Set<Int>?, inserts: Set<Int>?, moves: Set<Move>?, rowChanges: [RowChanges]?) {
self.deletes = deletes
self.inserts = inserts
self.moves = moves
self.rowChanges = rowChanges
}
}

View File

@@ -0,0 +1,64 @@
//
// UpdateSelectionOperation.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/22/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
class UpdateSelectionOperation: MainThreadOperation {
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public weak var operationDelegate: MainThreadOperationDelegate?
public var name: String? = "UpdateSelectionOperation"
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private var coordinator: SceneCoordinator
private var dataSource: MasterFeedDataSource
private var tableView: UITableView
private var animations: Animations
init(coordinator: SceneCoordinator, dataSource: MasterFeedDataSource, tableView: UITableView, animations: Animations) {
self.coordinator = coordinator
self.dataSource = dataSource
self.tableView = tableView
self.animations = animations
}
func run() {
if dataSource.snapshot().numberOfItems > 0 {
if let indexPath = coordinator.currentFeedIndexPath {
CATransaction.begin()
CATransaction.setCompletionBlock {
self.operationDelegate?.operationDidComplete(self)
}
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations)
CATransaction.commit()
} else {
if let indexPath = tableView.indexPathForSelectedRow {
if animations.contains(.select) {
CATransaction.begin()
CATransaction.setCompletionBlock {
self.operationDelegate?.operationDidComplete(self)
}
tableView.deselectRow(at: indexPath, animated: true)
CATransaction.commit()
} else {
tableView.deselectRow(at: indexPath, animated: false)
self.operationDelegate?.operationDidComplete(self)
}
} else {
self.operationDelegate?.operationDidComplete(self)
}
}
} else {
self.operationDelegate?.operationDidComplete(self)
}
}
}