diff --git a/Modules/Account/Sources/Account/Feed.swift b/Modules/Account/Sources/Account/Feed.swift index 1af9138ed..929873594 100644 --- a/Modules/Account/Sources/Account/Feed.swift +++ b/Modules/Account/Sources/Account/Feed.swift @@ -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) { diff --git a/Modules/Account/Sources/Account/Folder.swift b/Modules/Account/Sources/Account/Folder.swift index 31bdc5e2f..89f175b50 100644 --- a/Modules/Account/Sources/Account/Folder.swift +++ b/Modules/Account/Sources/Account/Folder.swift @@ -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 diff --git a/Shared/AppImage.swift b/Shared/AppImage.swift index 68d0ff05f..5793dbd2c 100644 --- a/Shared/AppImage.swift +++ b/Shared/AppImage.swift @@ -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 } diff --git a/Shared/Extensions/SmallIconProvider.swift b/Shared/Extensions/SmallIconProvider.swift index e1143b5a1..3d7b10191 100644 --- a/Shared/Extensions/SmallIconProvider.swift +++ b/Shared/Extensions/SmallIconProvider.swift @@ -37,6 +37,6 @@ extension Feed: SmallIconProvider { extension Folder: SmallIconProvider { var smallIcon: IconImage? { - AppImage.folder + AppImage.folderIconImage } } diff --git a/Shared/SmartFeeds/PseudoFeed.swift b/Shared/SmartFeeds/PseudoFeed.swift index 2126cd808..00edb2dd6 100644 --- a/Shared/SmartFeeds/PseudoFeed.swift +++ b/Shared/SmartFeeds/PseudoFeed.swift @@ -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 { } diff --git a/iOS/MainWindow/Sidebar/SidebarTree.swift b/iOS/MainWindow/Sidebar/SidebarTree.swift index 50cdbf0b9..c5fa07d01 100644 --- a/iOS/MainWindow/Sidebar/SidebarTree.swift +++ b/iOS/MainWindow/Sidebar/SidebarTree.swift @@ -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 it’s 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 it’s just a few items. diff --git a/iOS/MainWindow/Sidebar/SidebarViewController.swift b/iOS/MainWindow/Sidebar/SidebarViewController.swift index 70cee3577..8974c8a8c 100644 --- a/iOS/MainWindow/Sidebar/SidebarViewController.swift +++ b/iOS/MainWindow/Sidebar/SidebarViewController.swift @@ -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 -// 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 { (cell, _, item) in -// var content = UIListContentConfiguration.cell() -// content.text = item.title -// content.image = item.icon -// cell.contentConfiguration = content -// } -// -// dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in -// return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) -// } -// -// let headerRegistration = UICollectionView.SupplementaryRegistration(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() -// -// 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 + 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 isn’t 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 { 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(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() + + 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) + } +} diff --git a/iOS/ShareExtension/ShareFolderPickerController.swift b/iOS/ShareExtension/ShareFolderPickerController.swift index 0e5446a8f..8731fe6ee 100644 --- a/iOS/ShareExtension/ShareFolderPickerController.swift +++ b/iOS/ShareExtension/ShareFolderPickerController.swift @@ -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 ?? ""