Make progress on iOS SidebarViewController.

This commit is contained in:
Brent Simmons
2025-02-08 22:05:10 -08:00
parent e417f782f9
commit 15f46206ee
8 changed files with 380 additions and 233 deletions

View File

@@ -193,9 +193,11 @@ public final class Feed: SidebarItem, Renamable, Hashable {
if let s = name, !s.isEmpty {
return s
}
return NSLocalizedString("Untitled", comment: "Feed name")
return Self.untitledName
}
public static let untitledName = NSLocalizedString("Untitled", comment: "Feed name")
// MARK: - Renamable
public func rename(to newName: String, completion: @escaping (Result<Void, Error>) -> Void) {

View File

@@ -42,7 +42,7 @@ public final class Folder: SidebarItem, Renamable, Container, Hashable {
}
}
static let untitledName = NSLocalizedString("Untitled ƒ", comment: "Folder name")
public static let untitledName = NSLocalizedString("Untitled ƒ", comment: "Folder name")
public let folderID: Int // not saved: per-run only
public var externalID: String?
static var incrementingID = 0

View File

@@ -47,6 +47,7 @@ struct AppImage {
static var faviconTemplate = appImage("faviconTemplateImage")
static var filterActive = systemImage("line.horizontal.3.decrease.circle.fill")
static var filterInactive = systemImage("line.horizontal.3.decrease.circle")
static var folder = systemImage("folder")
static var markAllAsRead = appImage("markAllAsRead")
static let nnwFeedIcon = RSImage(named: "nnwFeedIcon")!
static var share = systemImage("square.and.arrow.up")
@@ -122,7 +123,7 @@ extension AppImage {
return IconImage(coloredImage, isSymbol: true, isBackgroundSuppressed: true, preferredColor: preferredColor.cgColor)
}()
static var folder: IconImage = {
static var folderIconImage: IconImage = {
let image = systemImage("folder")
let preferredColor = AppColor.accent
let coloredImage = image.tinted(with: preferredColor)
@@ -174,10 +175,7 @@ extension AppImage {
static var todayFeed = IconImage(today, isSymbol: true, isBackgroundSuppressed: true, preferredColor: UIColor.systemOrange.cgColor)
static var unreadFeed = IconImage(allUnread, isSymbol: true, isBackgroundSuppressed: true, preferredColor: AppColor.secondaryAccent.cgColor)
static var folder: IconImage = {
let image = systemImage("folder.fill")
return IconImage(image, isSymbol: true, isBackgroundSuppressed: true, preferredColor: AppColor.secondaryAccent.cgColor)
}()
static var folderIconImage = IconImage(AppImage.folder, isSymbol: true, isBackgroundSuppressed: true, preferredColor: AppColor.secondaryAccent.cgColor)
#endif
}

View File

@@ -37,6 +37,6 @@ extension Feed: SmallIconProvider {
extension Folder: SmallIconProvider {
var smallIcon: IconImage? {
AppImage.folder
AppImage.folderIconImage
}
}

View File

@@ -13,7 +13,7 @@ import Articles
import Account
import RSCore
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider, PasteboardWriterOwner {
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider, PasteboardWriterOwner, DisplayNameProvider {
}
@@ -24,7 +24,7 @@ import Articles
import Account
import RSCore
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider {
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider, DisplayNameProvider {
}

View File

@@ -7,16 +7,41 @@
//
import Foundation
import UIKit
import Account
typealias SectionID = Int
typealias ItemID = Int
protocol Section: Identifiable {}
protocol Item: Identifiable {}
// MARK: - Protocols
@MainActor protocol SidebarContainer: Identifiable {
/// A top-level collapsible item.
///
/// The Smart Feeds group and active `Account`s are `Section`s.
@MainActor protocol Section: SidebarContainer, Identifiable where ID == SectionID {
var title: String { get }
/// All `Item`s in the `Section`, even if contained by a `SidebarFolder`
/// in that section.
func flattenedItems() -> [any Item]
}
/// `Item`s are contained by `Sections`. They never appear at the top level.
/// They are hidden when a `Section` is collapsed.
///
/// Items are smart feeds, folders, and feeds.
@MainActor protocol Item: Identifiable where ID == ItemID {
var title: String { get }
var image: UIImage? { get }
}
/// A `SidebarContainer` contains `Item`s and is collapsible.
///
/// All `Section`s are `SidebarContainer`s.
///
/// `SidebarFolder` is also a `SidebarContainer`,
/// though its not a `Section`, because it contains `Item`s and is collapsible.
@MainActor protocol SidebarContainer: Identifiable where ID == SectionID {
var isExpanded: Bool { get set }
var items: [any Item] { get }
@@ -25,6 +50,13 @@ protocol Item: Identifiable {}
extension SidebarContainer {
// These dictionaries make it fast to look up, given a Feed or Folder,
// the existing SidebarFeed and SidebarFolder.
// This way we can preserve identity across rebuilds of the tree.
// (Meaning: after rebuilding the tree, a given Feed in a given Account
// or Folder should have the same SidebarFeed object with the same id.
// Same with Folders and SidebarFolder.)
func createFeedsDictionary() -> [String: SidebarFeed] {
var d = [String: SidebarFeed]() // feedID: SidebarFeed
for item in items where item is SidebarFeed {
@@ -49,8 +81,13 @@ extension SidebarContainer {
@MainActor final class SidebarSmartFeedsFolder: Section, SidebarContainer {
let id = createID()
var isExpanded = true
var title: String {
SmartFeedsController.shared.nameForDisplay
}
var isExpanded = true
let items: [any Item] = [
SidebarSmartFeed(SmartFeedsController.shared.todayFeed),
SidebarSmartFeed(SmartFeedsController.shared.unreadFeed),
@@ -59,8 +96,11 @@ extension SidebarContainer {
func updateItems() {
}
}
func flattenedItems() -> [any Item] {
items
}
}
// MARK: - SidebarSmartFeed
@@ -69,6 +109,14 @@ extension SidebarContainer {
let id = createID()
let smartFeed: any PseudoFeed
var title: String {
smartFeed.nameForDisplay
}
var image: UIImage? {
smartFeed.smallIcon?.image
}
init(_ smartFeed: any PseudoFeed) {
self.smartFeed = smartFeed
}
@@ -79,6 +127,10 @@ extension SidebarContainer {
@MainActor final class SidebarAccount: Section, SidebarContainer {
let id = createID()
var title: String {
account?.nameForDisplay ?? "Untitled Account"
}
let accountID: String
weak var account: Account?
var isExpanded = true
@@ -133,6 +185,20 @@ extension SidebarContainer {
// TODO: sort feeds
items = sidebarFeeds + sidebarFolders
}
func flattenedItems() -> [any Item] {
var temp = items
for item in items {
temp.append(item)
if let container = item as? any SidebarContainer {
temp.append(contentsOf: container.items)
}
}
return temp
}
}
// MARK: - SidebarFolder
@@ -140,6 +206,15 @@ extension SidebarContainer {
@MainActor final class SidebarFolder: Item, SidebarContainer {
let id = createID()
var title: String {
folder?.nameForDisplay ?? Folder.untitledName
}
var image: UIImage? {
AppImage.folder
}
let folderID: Int
weak var folder: Folder?
var isExpanded = false
@@ -186,6 +261,18 @@ extension SidebarContainer {
let feedID: String
weak var feed: Feed?
var title: String {
feed?.nameForDisplay ?? Feed.untitledName
}
var image: UIImage? {
if let feed {
return IconImageCache.shared.imageForFeed(feed)?.image
} else {
return nil
}
}
init(_ feed: Feed) {
self.feedID = feed.feedID
self.feed = feed
@@ -197,17 +284,28 @@ extension SidebarContainer {
@MainActor final class SidebarTree {
var sections = [any Section]()
private var idsToItems = [ItemID: any Item]()
private var idsToSections = [SectionID: any Section]()
private lazy var sidebarSmartFeedsFolder = SidebarSmartFeedsFolder()
func rebuildTree() {
func section(with id: SectionID) -> (any Section)? {
idsToSections[id]
}
func item(with id: ItemID) -> (any Item)? {
idsToItems[id]
}
func rebuild() {
// In my testing, with two accounts and hundreds of feeds,
// this function takes 2-3 milliseconds.
rebuildSections()
for section in sections where section is SidebarAccount {
let sidebarAccount = section as! SidebarAccount
sidebarAccount.updateItems()
}
updateAllItems()
rebuildIDsToItems()
rebuildIDsToSections()
}
}
@@ -216,7 +314,6 @@ private extension SidebarTree {
func rebuildSections() {
var updatedSections = [any Section]()
updatedSections.append(sidebarSmartFeedsFolder)
for account in AccountManager.shared.activeAccounts {
@@ -232,6 +329,38 @@ private extension SidebarTree {
sections = updatedSections
}
func updateAllItems() {
for section in sections where section is SidebarAccount {
let sidebarAccount = section as! SidebarAccount
sidebarAccount.updateItems()
}
}
func rebuildIDsToItems() {
var d = [ItemID: any Item]()
for section in sections {
for item in section.flattenedItems() {
d[item.id] = item
}
}
idsToItems = d
}
func rebuildIDsToSections() {
var d = [SectionID: any Section]()
for section in sections {
d[section.id] = section
}
idsToSections = d
}
func existingSection(for account: Account) -> (any Section)? {
// Linear search through array because its just a few items.

View File

@@ -1,213 +1,231 @@
////
//// SidebarViewController.swift
//// NetNewsWire-iOS
////
//// Created by Brent Simmons on 2/2/25.
//// Copyright © 2025 Ranchero Software. All rights reserved.
////
//
//import Foundation
//import UIKit
//import Account
// SidebarViewController.swift
// NetNewsWire-iOS
//
//final class SidebarViewController: UICollectionViewController {
// Created by Brent Simmons on 2/2/25.
// Copyright © 2025 Ranchero Software. All rights reserved.
//
// typealias DataSource = UICollectionViewDiffableDataSource<SectionID, SidebarItemID>
// private lazy var dataSource = createDataSource()
//
// private lazy var filterButton = UIBarButtonItem(
// image: AppImage.filterInactive,
// style: .plain,
// target: self,
// action: #selector(toggleFilter(_:))
// )
//
// private lazy var settingsButton = UIBarButtonItem(
// image: AppImage.settings,
// style: .plain,
// target: self,
// action: #selector(showSettings(_:))
// )
//
// private lazy var addNewItemButton = UIBarButtonItem(systemItem: .add)
//
// private lazy var refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
// private lazy var refreshProgressView: RefreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as! RefreshProgressView
//
// private lazy var flexibleSpaceBarButtonItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
//
// private lazy var toolbar = UIToolbar()
//
// init() {
// var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar)
// configuration.headerMode = .supplementary
// let layout = UICollectionViewCompositionalLayout.list(using: configuration)
//
// super.init(collectionViewLayout: layout)
// }
//
// required init?(coder: NSCoder) {
// fatalError("init(coder:) has not been implemented")
// }
//
// override func viewDidLoad() {
//
// super.viewDidLoad()
//
// collectionView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0)
//
// title = "Feeds"
// navigationController?.navigationBar.prefersLargeTitles = true
// navigationItem.rightBarButtonItem = filterButton
//
// toolbar.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(toolbar)
//
// NSLayoutConstraint.activate([
// toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
// toolbar.heightAnchor.constraint(equalToConstant: 44)
// ])
//
// let toolbarItems = [
// settingsButton,
// flexibleSpaceBarButtonItem,
// refreshProgressItemButton,
// flexibleSpaceBarButtonItem,
// addNewItemButton
// ]
// toolbar.setItems(toolbarItems, animated: false)
//
// collectionView.register(SidebarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: SidebarHeaderView.reuseIdentifier)
//
// applySnapshot()
// }
//}
//
//// MARK: - Actions
//
//// TODO: Implement actions
//
//extension SidebarViewController {
//
// @objc func toggleFilter(_ sender: Any) {
// }
//
// @objc func showSettings(_ sender: Any?) {
// }
//}
//
//// MARK: - SidebarHeaderViewDelegate
//
//extension SidebarViewController: SidebarHeaderViewDelegate {
//
// func sidebarHeaderViewUserDidToggleExpanded(_ sidebarHeaderView: SidebarHeaderView) {
// var section = sections[sidebarHeaderView.sectionIndex]
// section.isExpanded.toggle()
// applySnapshot()
// }
//}
//
//private extension SidebarViewController {
//
// func createDataSource() -> DataSource {
// let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, SidebarItem> { (cell, _, item) in
// var content = UIListContentConfiguration.cell()
// content.text = item.title
// content.image = item.icon
// cell.contentConfiguration = content
// }
//
// dataSource = UICollectionViewDiffableDataSource<Section, SidebarItem>(collectionView: collectionView) { (collectionView, indexPath, item) in
// return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
// }
//
// let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { header, _, indexPath in
//
// let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
//
// var content = UIListContentConfiguration.sidebarHeader()
// content.text = sectionTitle(for: section)
// header.contentConfiguration = content
//
// // Add a disclosure indicator to show collapsible behavior
// let button = UIButton(type: .system)
// button.setImage(UIImage(systemName: "chevron.down"), for: .normal)
// button.tintColor = .secondaryLabel
// button.addTarget(self, action: #selector(self.toggleSection(_:)), for: .touchUpInside)
// button.tag = indexPath.section
// header.accessories = [.customView(configuration: .init(customView: button, placement: .trailing(displayed: .always)))]
//
// // Rotate chevron based on section state
// button.transform = sectionStates[section] == true ? .identity : CGAffineTransform(rotationAngle: -.pi / 2)
// }
//
// dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
// let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SidebarHeaderView.reuseIdentifier, for: indexPath) as! SidebarHeaderView
// let section = self.sections[indexPath.section]
// header.configure(title: section.title, sectionIndex: indexPath.section, delegate: self)
// return header
// }
//
// return dataSource
// }
//
// func applySnapshot() {
// var snapshot = NSDiffableDataSourceSnapshot<Section, SidebarItem>()
//
// for section in sections {
// snapshot.appendSections([section])
// if section.isExpanded {
// snapshot.appendItems(section.items, toSection: section)
// }
// }
//
// dataSource.apply(snapshot, animatingDifferences: true)
// }
//}
//
//protocol SidebarHeaderViewDelegate: AnyObject {
//
// func sidebarHeaderViewUserDidToggleExpanded(_: SidebarHeaderView)
//}
//
//final class SidebarHeaderView: UICollectionReusableView {
//
// static let reuseIdentifier = "SidebarHeaderView"
//
// var sectionIndex: Int = 0
// var delegate: SidebarHeaderViewDelegate?
//
// override init(frame: CGRect) {
// super.init(frame: frame)
// backgroundColor = .darkGray
//
// let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleExpanded(_:)))
// addGestureRecognizer(tapGesture)
// }
//
// required init?(coder: NSCoder) {
// fatalError("init(coder:) has not been implemented")
// }
//
// func configure(title: String, sectionIndex: Int, delegate: SidebarHeaderViewDelegate) {
// self.sectionIndex = sectionIndex
// self.delegate = delegate
//
// let label = UILabel(frame: bounds)
// label.text = title
// label.textColor = .white
// label.textAlignment = .center
// addSubview(label)
// }
//
// @objc func toggleExpanded(_ sender: Any?) {
// delegate?.sidebarHeaderViewUserDidToggleExpanded(self)
//// guard let controller else {
//// return
//// }
//// controller.sections[sectionIndex].state.toggle()
//// controller.applySnapshot()
// }
//}
import Foundation
import UIKit
import Account
final class SidebarViewController: UICollectionViewController {
private let tree = SidebarTree()
typealias DataSource = UICollectionViewDiffableDataSource<SectionID, ItemID>
private lazy var dataSource = createDataSource()
private lazy var filterButton = UIBarButtonItem(
image: AppImage.filterInactive,
style: .plain,
target: self,
action: #selector(toggleFilter(_:))
)
private lazy var settingsButton = UIBarButtonItem(
image: AppImage.settings,
style: .plain,
target: self,
action: #selector(showSettings(_:))
)
private lazy var addNewItemButton = UIBarButtonItem(systemItem: .add)
private lazy var refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
private lazy var refreshProgressView: RefreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as! RefreshProgressView
private lazy var flexibleSpaceBarButtonItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
private lazy var toolbar = UIToolbar()
init() {
var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar)
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
super.init(collectionViewLayout: layout)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .feedSettingDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
super.viewDidLoad()
collectionView.backgroundColor = .systemRed
title = "Feeds"
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.rightBarButtonItem = filterButton
toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar)
NSLayoutConstraint.activate([
toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
let toolbarItems = [
settingsButton,
flexibleSpaceBarButtonItem,
refreshProgressItemButton,
flexibleSpaceBarButtonItem,
addNewItemButton
]
toolbar.setItems(toolbarItems, animated: false)
tree.rebuild()
applySnapshot()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Make collection view aware of toolbar  so that bottom isnt trapped under the toolbar.
let toolbarHeight = toolbar.frame.height
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0)
collectionView.verticalScrollIndicatorInsets.bottom = toolbarHeight
}
func updateVisibleRows() {
var itemIDsToReload = [ItemID]()
for indexPath in collectionView.indexPathsForVisibleItems {
if let itemID = dataSource.itemIdentifier(for: indexPath) {
itemIDsToReload.append(itemID)
}
}
if !itemIDsToReload.isEmpty {
var snapshot = dataSource.snapshot()
snapshot.reloadItems(itemIDsToReload)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
// MARK: - Notifications
@objc func faviconDidBecomeAvailable(_ note: Notification) {
updateVisibleRows()
}
@objc func feedIconDidBecomeAvailable(_ note: Notification) {
updateVisibleRows()
}
@objc func feedSettingDidChange(_ note: Notification) {
updateVisibleRows()
}
@objc func displayNameDidChange(_ note: Notification) {
updateVisibleRows()
}
}
// MARK: - Actions
// TODO: Implement actions
extension SidebarViewController {
@objc func toggleFilter(_ sender: Any) {
}
@objc func showSettings(_ sender: Any?) {
}
}
private extension SidebarViewController {
static let imageSize = CGSize(width: 24, height: 24)
func createDataSource() -> DataSource {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ItemID> { cell, _, itemID in
guard let item = self.tree.item(with: itemID) else {
preconditionFailure("Expected item \(itemID) to exist in sidebar tree.")
}
var config = UIListContentConfiguration.cell()
config.text = item.title
// TODO: different configuration for SF Symbols?
//config.imageProperties.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 18, weight: .regular)
config.image = item.image
config.imageProperties.cornerRadius = 6
config.imageProperties.reservedLayoutSize = Self.imageSize
config.imageProperties.maximumSize = Self.imageSize
// config.imageProperties.tintColor = .secondaryLabel
cell.contentConfiguration = config
}
let dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemID in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemID)
}
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { header, _, indexPath in
let sectionID = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
guard let section = self.tree.section(with: sectionID) else {
preconditionFailure("Expected sectionID and section for indexPath \(indexPath) to exist in sidebar tree.")
}
var content = UIListContentConfiguration.header()
content.text = section.title
header.contentConfiguration = content
// Add a disclosure indicator to show collapsible behavior
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "chevron.down"), for: .normal)
button.tintColor = .secondaryLabel
// button.addTarget(self, action: #selector(self.toggleSection(_:)), for: .touchUpInside)
button.tag = indexPath.section
header.accessories = [.customView(configuration: .init(customView: button, placement: .trailing(displayed: .always)))]
// Rotate chevron based on section state
button.transform = section.isExpanded ? .identity : CGAffineTransform(rotationAngle: -.pi / 2)
}
dataSource.supplementaryViewProvider = { collectionView, _, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
return dataSource
}
func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<SectionID, ItemID>()
for section in tree.sections {
let sectionID = section.id
snapshot.appendSections([sectionID])
if section.isExpanded {
let itemIDs = section.items.map { $0.id }
snapshot.appendItems(itemIDs, toSection: sectionID)
// TODO: handle folders
}
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}

View File

@@ -48,7 +48,7 @@ final class ShareFolderPickerController: UITableViewController {
if let account = container as? ExtensionAccount {
cell.icon.image = AppImage.account(account.type)
} else {
cell.icon.image = AppImage.folder.image
cell.icon.image = AppImage.folder
}
cell.label?.text = container?.name ?? ""