diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index ce578f1d86747a218eeb30943494deb8173a1249..136f9b12ccc0bf434edc0a7652c94372f5dbd8c5 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -30,6 +30,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke private var dataSource: UICollectionViewDiffableDataSource! private var collapsed: Set = [] + private lazy var sync = SyncIndicator(in: self) private var selected: Item? var onOpen: ((Tab, Notebook) -> Void)? @@ -111,7 +112,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) navigationItem.searchController = SearchViewController.controller() - toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()] + toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()] NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in guard let self else { return } reload() @@ -714,6 +715,7 @@ final class PagesViewController: UITableViewController { private(set) var section: Section? private var items: [Item] = [] + private lazy var sync = SyncIndicator(in: self) private var selected: String? private let search = SearchViewController.controller() /// Opens a page of the section. @@ -734,7 +736,7 @@ final class PagesViewController: UITableViewController { title: "New Page", image: UIImage(systemName: "square.and.pencil"), primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) toolbarItems = [ - .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose, + .flexibleSpace(), sync, .flexibleSpace(), compose, ] NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift index 6aa4f2e6b783952d0964f578acdd170ae7b5f3ba..a779d4b5e08c03c164be71d2b7c0f75584934699 100644 --- a/apps/ios/Snowbound/Sync.swift +++ b/apps/ios/Snowbound/Sync.swift @@ -97,9 +97,17 @@ enum Sync { } /// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync -/// sheet. -final class SyncIndicator: UIButton { - init() { +/// sheet. The item leaves `owner`'s toolbar while there is nothing to report (hidden, its +/// custom view would leave the bar's glass behind), so the owner keeps it. +final class SyncIndicator: UIBarButtonItem { + private let button = UIButton(type: .system) + private weak var owner: UIViewController? + /// Where in the toolbar the item goes back to. + private var slot = 0 + + init(in owner: UIViewController) { + self.owner = owner + super.init() var configuration = UIButton.Configuration.plain() configuration.imagePadding = 4 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1) @@ -110,9 +118,9 @@ final class SyncIndicator: UIButton { attributes.font = .preferredFont(forTextStyle: .caption1) return attributes } - super.init(frame: .zero) - self.configuration = configuration - addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered) + button.configuration = configuration + button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered) + customView = button for name in [Sync.changed, Section.changed] { NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) } @@ -124,23 +132,35 @@ final class SyncIndicator: UIButton { @objc private func refresh() { Sync.status { [weak self] notebooks in let sections = notebooks.flatMap(\.1) - guard let self, let worst = sections.map(\.state).max() else { - self?.isHidden = true - return - } - isHidden = false + guard let self else { return } + guard let worst = sections.map(\.state).max() else { return show(false) } let queued = sections.map(\.queued).reduce(0, +) let label = Sync.label(worst) - configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label - configuration?.image = UIImage(systemName: Sync.symbol(worst)) - configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel - accessibilityLabel = "Sync status: \(configuration?.title ?? label)" - sizeToFit() + button.configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label + button.configuration?.image = UIImage(systemName: Sync.symbol(worst)) + button.configuration?.baseForegroundColor = + worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel + button.accessibilityLabel = "Sync status: \(button.configuration?.title ?? label)" + button.sizeToFit() + show(true) } } + /// Takes the item out of its owner's toolbar, or puts it back where it was. + private func show(_ shown: Bool) { + guard let owner, var items = owner.toolbarItems else { return } + switch (shown, items.firstIndex { $0 === self }) { + case (true, nil): items.insert(self, at: min(slot, items.count)) + case (false, let at?): + slot = at + items.remove(at: at) + default: return + } + owner.setToolbarItems(items, animated: true) + } + private func present() { - guard let controller = window?.rootViewController else { return } + guard let controller = button.window?.rootViewController else { return } let navigation = UINavigationController(rootViewController: SyncViewController()) navigation.sheetPresentationController?.detents = [.medium(), .large()] navigation.sheetPresentationController?.prefersGrabberVisible = true