From 683db93d77dc433cfb57e239957fcf77252a7ced Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:12:35 +1100 Subject: [PATCH 1/2] Add bookmarks list filtering by read status, star color, and text search Adds a filter popover and search bar to the bookmarks screen, allowing users to filter by All/Unread/Read, by star color category, or by title/author text search. The filter menu uses UIGlassEffect on iOS 26+ with a solid fallback for older versions, and respects Reduce Motion and Reduce Transparency accessibility settings. --- App/Data Sources/ThreadListDataSource.swift | 36 +- App/Resources/Localizable.xcstrings | 132 +++ .../BookmarksTableViewController.swift | 922 +++++++++++++++++- 3 files changed, 1071 insertions(+), 19 deletions(-) diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index 083151829..2bd3481f9 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -9,6 +9,14 @@ import CoreData import os import UIKit +enum BookmarkFilter { + case all + case unreadOnly + case readOnly + case starCategory(StarCategory) + case textSearch(String) +} + private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ThreadListDataSource") final class ThreadListDataSource: NSObject { @@ -20,10 +28,30 @@ final class ThreadListDataSource: NSObject { private let showsTagAndRating: Bool private let tableView: UITableView - convenience init(bookmarksSortedByUnread sortedByUnread: Bool, showsTagAndRating: Bool, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + convenience init(bookmarksSortedByUnread sortedByUnread: Bool, showsTagAndRating: Bool, filter: BookmarkFilter, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { let fetchRequest = AwfulThread.makeFetchRequest() - fetchRequest.predicate = NSPredicate(format: "%K == YES && %K > 0", #keyPath(AwfulThread.bookmarked), #keyPath(AwfulThread.bookmarkListPage)) + var predicates = [ + NSPredicate(format: "%K == YES && %K > 0", #keyPath(AwfulThread.bookmarked), #keyPath(AwfulThread.bookmarkListPage)) + ] + + switch filter { + case .all: + break + case .unreadOnly: + predicates.append(NSPredicate(format: "%K == YES", #keyPath(AwfulThread.anyUnreadPosts))) + case .readOnly: + predicates.append(NSPredicate(format: "%K == NO", #keyPath(AwfulThread.anyUnreadPosts))) + case .starCategory(let category): + predicates.append(NSPredicate(format: "%K == %d", "starCategory", category.rawValue)) + case .textSearch(let searchText): + let titlePredicate = NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(AwfulThread.title), searchText) + let authorPredicate = NSPredicate(format: "%K.%K CONTAINS[cd] %@", #keyPath(AwfulThread.author), #keyPath(User.username), searchText) + let textPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [titlePredicate, authorPredicate]) + predicates.append(textPredicate) + } + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) fetchRequest.sortDescriptors = { var descriptors = [NSSortDescriptor(key: #keyPath(AwfulThread.bookmarkListPage), ascending: true)] @@ -36,6 +64,10 @@ final class ThreadListDataSource: NSObject { try self.init(managedObjectContext: managedObjectContext, fetchRequest: fetchRequest, tableView: tableView, ignoreSticky: true, showsTagAndRating: showsTagAndRating, placeholder: .thread(tintColor: nil)) } + + convenience init(bookmarksSortedByUnread sortedByUnread: Bool, showsTagAndRating: Bool, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + try self.init(bookmarksSortedByUnread: sortedByUnread, showsTagAndRating: showsTagAndRating, filter: .all, managedObjectContext: managedObjectContext, tableView: tableView) + } convenience init(forum: Forum, sortedByUnread: Bool, showsTagAndRating: Bool, threadTagFilter: Set, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { let fetchRequest = AwfulThread.makeFetchRequest() diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 0324b5296..8c2e7d6ce 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -68,6 +68,138 @@ } } }, + "bookmarks.filter.all" : { + "comment" : "Segmented control label for showing all bookmarks.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All" + } + } + } + }, + "bookmarks.filter.unread" : { + "comment" : "Segmented control label for showing only unread bookmarks.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unread" + } + } + } + }, + "bookmarks.filter.read" : { + "comment" : "Segmented control label for showing only read bookmarks.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read" + } + } + } + }, + "bookmarks.filter.button.accessibility-label" : { + "comment" : "Accessibility label for the filter button in the bookmarks navigation bar.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter bookmarks" + } + } + } + }, + "bookmarks.filter.menu.accessibility-label" : { + "comment" : "Accessibility label for the filter menu popover.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter Menu" + } + } + } + }, + "bookmarks.filter.menu.accessibility-hint" : { + "comment" : "Accessibility hint for the filter menu popover.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter bookmarks by read status or star color" + } + } + } + }, + "bookmarks.filter.star.accessibility-label" : { + "comment" : "Accessibility label for a star color filter button. %@ is the color name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter by %@ star" + } + } + } + }, + "bookmarks.filter.star.accessibility-hint.selected" : { + "comment" : "Accessibility hint for a currently selected star color filter button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently selected. Double-tap to deselect." + } + } + } + }, + "bookmarks.filter.star.accessibility-hint.unselected" : { + "comment" : "Accessibility hint for an unselected star color filter button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to filter by this star color." + } + } + } + }, + "bookmarks.search.button.accessibility-label" : { + "comment" : "Accessibility label for the search button in the bookmarks navigation bar.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search bookmarks" + } + } + } + }, + "bookmarks.search.placeholder" : { + "comment" : "Placeholder text for the bookmarks search bar.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search by title or author\u2026" + } + } + } + }, "cancel" : { "comment" : "Title of button that dismisses without taking any action.", "localizations" : { diff --git a/App/View Controllers/Threads/BookmarksTableViewController.swift b/App/View Controllers/Threads/BookmarksTableViewController.swift index 0e4aec01e..ecafdcc7e 100644 --- a/App/View Controllers/Threads/BookmarksTableViewController.swift +++ b/App/View Controllers/Threads/BookmarksTableViewController.swift @@ -13,6 +13,565 @@ import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BookmarksTableViewController") +private class FilterMenuViewController: UIViewController { + // MARK: - Constants + private enum Layout { + static let cornerRadius: CGFloat = 13 + static let backdropOpacity: CGFloat = 0.2 + static let contentPadding: CGFloat = 16 + static let segmentedControlHeight: CGFloat = 32 + static let colorButtonSize: CGFloat = 36 + static let colorButtonSpacing: CGFloat = 20 + static let verticalSpacing: CGFloat = 16 + static let containerWidth: CGFloat = 280 + static let containerHeight: CGFloat = 180 + static let shadowRadius: CGFloat = 20 + static let shadowOpacity: Float = 0.15 + } + + // MARK: - Properties + private let segmentedControl: UISegmentedControl + private let stackView: UIStackView + private var currentFilter: BookmarkFilter + private let starCategories: [StarCategory] + private let theme: Theme + private let onFilterSelected: (BookmarkFilter) -> Void + private let enableHaptics: Bool + var onDismiss: (() -> Void)? + + private var visualEffectView: UIVisualEffectView? + private lazy var contentContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = Layout.cornerRadius + view.layer.masksToBounds = false + + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = Layout.shadowOpacity + view.layer.shadowRadius = Layout.shadowRadius + view.layer.shadowOffset = CGSize(width: 0, height: 4) + + return view + }() + private var sourceButtonRect: CGRect? + private var sourceButtonSuperview: UIView? + private var positioningConstraints: [NSLayoutConstraint] = [] + private var colorButtons: [UIButton] = [] + + init(currentFilter: BookmarkFilter, theme: Theme, enableHaptics: Bool, onFilterSelected: @escaping (BookmarkFilter) -> Void) { + self.currentFilter = currentFilter + self.theme = theme + self.enableHaptics = enableHaptics + self.onFilterSelected = onFilterSelected + self.starCategories = Array(StarCategory.allCases.filter { $0 != .none }) + + self.segmentedControl = UISegmentedControl(items: [ + LocalizedString("bookmarks.filter.all"), + LocalizedString("bookmarks.filter.unread"), + LocalizedString("bookmarks.filter.read"), + ]) + + switch currentFilter { + case .all: segmentedControl.selectedSegmentIndex = 0 + case .unreadOnly: segmentedControl.selectedSegmentIndex = 1 + case .readOnly: segmentedControl.selectedSegmentIndex = 2 + default: segmentedControl.selectedSegmentIndex = 0 + } + + self.stackView = UIStackView() + + super.init(nibName: nil, bundle: nil) + } + + func setSourceButtonRect(_ rect: CGRect) { + sourceButtonRect = rect + sourceButtonSuperview = view + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + view.backgroundColor = UIColor.black.withAlphaComponent(Layout.backdropOpacity) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backdropTapped(_:))) + view.addGestureRecognizer(tapGesture) + + view.addSubview(contentContainerView) + + // Use glass effect for iOS 26+ when accessibility allows, otherwise solid background + if #available(iOS 26.0, *), !UIAccessibility.isReduceTransparencyEnabled { + let glassEffect = UIGlassEffect() + let effectView = UIVisualEffectView(effect: glassEffect) + effectView.translatesAutoresizingMaskIntoConstraints = false + effectView.layer.cornerRadius = Layout.cornerRadius + effectView.layer.masksToBounds = true + + let themeMode = theme[string: "mode"] + effectView.overrideUserInterfaceStyle = themeMode == "dark" ? .dark : .light + + visualEffectView = effectView + + contentContainerView.addSubview(effectView) + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: contentContainerView.topAnchor), + effectView.leadingAnchor.constraint(equalTo: contentContainerView.leadingAnchor), + effectView.trailingAnchor.constraint(equalTo: contentContainerView.trailingAnchor), + effectView.bottomAnchor.constraint(equalTo: contentContainerView.bottomAnchor) + ]) + } else { + contentContainerView.backgroundColor = theme["sheetBackgroundColor"] + contentContainerView.layer.masksToBounds = true + } + } + + @objc private func backdropTapped(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: view) + if !contentContainerView.frame.contains(location) { + if UIAccessibility.isReduceMotionEnabled { + UIView.animate( + withDuration: 0.2, + animations: { [weak self] in + self?.contentContainerView.alpha = 0 + self?.view.backgroundColor = UIColor.black.withAlphaComponent(0) + }, + completion: { [weak self] _ in + self?.dismiss(animated: false) { [weak self] in + self?.onDismiss?() + } + } + ) + } else { + UIView.animate( + withDuration: 0.25, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0, + options: [.curveEaseIn], + animations: { [weak self] in + self?.contentContainerView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + self?.contentContainerView.alpha = 0 + self?.view.backgroundColor = UIColor.black.withAlphaComponent(0) + }, + completion: { [weak self] _ in + self?.dismiss(animated: false) { [weak self] in + self?.onDismiss?() + } + } + ) + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupContent() + updateFilterUI() + + view.accessibilityViewIsModal = true + contentContainerView.accessibilityLabel = LocalizedString("bookmarks.filter.menu.accessibility-label") + contentContainerView.accessibilityHint = LocalizedString("bookmarks.filter.menu.accessibility-hint") + + contentContainerView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + contentContainerView.alpha = 0 + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if UIAccessibility.isReduceMotionEnabled { + UIView.animate(withDuration: 0.2) { [weak self] in + self?.contentContainerView.transform = .identity + self?.contentContainerView.alpha = 1.0 + } + } else { + UIView.animate( + withDuration: 0.35, + delay: 0, + usingSpringWithDamping: 0.75, + initialSpringVelocity: 0.5, + options: [.curveEaseOut], + animations: { [weak self] in + self?.contentContainerView.transform = .identity + self?.contentContainerView.alpha = 1.0 + } + ) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if positioningConstraints.isEmpty { + positionContentContainer() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isBeingDismissed || isMovingFromParent { + segmentedControl.isHidden = true + stackView.isHidden = true + colorButtons.forEach { $0.isHidden = true } + } + } + + private func setupContent() { + segmentedControl.backgroundColor = theme["navigationBarBackgroundColor"] + segmentedControl.selectedSegmentTintColor = theme["tintColor"] + segmentedControl.layer.cornerRadius = Layout.segmentedControlHeight / 2 + segmentedControl.clipsToBounds = true + + if let textColor = theme[uicolor: "listTextColor"] { + segmentedControl.setTitleTextAttributes([ + .foregroundColor: textColor, + .font: UIFont.systemFont(ofSize: 14, weight: .medium) + ], for: .normal) + } + if let selectedTextColor = theme[uicolor: "navigationBarBackgroundColor"] { + segmentedControl.setTitleTextAttributes([ + .foregroundColor: selectedTextColor, + .font: UIFont.systemFont(ofSize: 14, weight: .semibold) + ], for: .selected) + } + segmentedControl.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) + + stackView.axis = .vertical + stackView.spacing = Layout.verticalSpacing + stackView.alignment = .center + stackView.distribution = .equalCentering + stackView.translatesAutoresizingMaskIntoConstraints = false + + if #available(iOS 26.0, *), let effectView = visualEffectView { + effectView.contentView.addSubview(stackView) + } else { + contentContainerView.addSubview(stackView) + } + + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + segmentedControl.heightAnchor.constraint(equalToConstant: Layout.segmentedControlHeight), + segmentedControl.widthAnchor.constraint(equalToConstant: Layout.containerWidth - 2 * Layout.contentPadding) + ]) + + stackView.addArrangedSubview(segmentedControl) + + if !starCategories.isEmpty { + let colorGridView = UIView() + stackView.addArrangedSubview(colorGridView) + + let gridWidth = 3 * Layout.colorButtonSize + 2 * Layout.colorButtonSpacing + let gridHeight = 2 * Layout.colorButtonSize + Layout.verticalSpacing + + for (index, category) in starCategories.enumerated() { + let button = createColorButton(for: category) + colorButtons.append(button) + colorGridView.addSubview(button) + + let row = index / 3 + let col = index % 3 + + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: Layout.colorButtonSize), + button.heightAnchor.constraint(equalToConstant: Layout.colorButtonSize), + button.leadingAnchor.constraint(equalTo: colorGridView.leadingAnchor, constant: CGFloat(col) * (Layout.colorButtonSize + Layout.colorButtonSpacing)), + button.topAnchor.constraint(equalTo: colorGridView.topAnchor, constant: CGFloat(row) * (Layout.colorButtonSize + Layout.verticalSpacing)) + ]) + } + + colorGridView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + colorGridView.heightAnchor.constraint(equalToConstant: gridHeight), + colorGridView.widthAnchor.constraint(equalToConstant: gridWidth) + ]) + } + + if #available(iOS 26.0, *), let effectView = visualEffectView { + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: effectView.contentView.centerYAnchor), + stackView.centerXAnchor.constraint(equalTo: effectView.contentView.centerXAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: effectView.contentView.leadingAnchor, constant: Layout.contentPadding), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: effectView.contentView.trailingAnchor, constant: -Layout.contentPadding), + stackView.topAnchor.constraint(greaterThanOrEqualTo: effectView.contentView.topAnchor, constant: Layout.contentPadding), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: effectView.contentView.bottomAnchor, constant: -Layout.contentPadding) + ]) + } else { + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: contentContainerView.centerYAnchor), + stackView.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: contentContainerView.leadingAnchor, constant: Layout.contentPadding), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: contentContainerView.trailingAnchor, constant: -Layout.contentPadding), + stackView.topAnchor.constraint(greaterThanOrEqualTo: contentContainerView.topAnchor, constant: Layout.contentPadding), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentContainerView.bottomAnchor, constant: -Layout.contentPadding) + ]) + } + + preferredContentSize = CGSize(width: Layout.containerWidth, height: Layout.containerHeight) + } + + private func positionContentContainer() { + guard let sourceRect = sourceButtonRect else { + centerContentContainer() + return + } + + let convertedRect: CGRect + if let sourceSuperview = sourceButtonSuperview, sourceSuperview != view { + convertedRect = view.convert(sourceRect, from: sourceSuperview) + } else { + convertedRect = sourceRect + } + + let containerX = max(16, min(convertedRect.maxX - Layout.containerWidth, view.bounds.width - Layout.containerWidth - 16)) + + let navBarBottom = navigationController?.navigationBar.frame.maxY ?? 100 + let safeAreaTop = view.safeAreaInsets.top + let containerY = max(navBarBottom + 8, safeAreaTop + 8) + let finalY = (containerY + Layout.containerHeight > view.bounds.height - 44) + ? convertedRect.minY - Layout.containerHeight - 8 + : containerY + + NSLayoutConstraint.deactivate(positioningConstraints) + positioningConstraints = [ + contentContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: containerX), + contentContainerView.topAnchor.constraint(equalTo: view.topAnchor, constant: finalY), + contentContainerView.widthAnchor.constraint(equalToConstant: Layout.containerWidth), + contentContainerView.heightAnchor.constraint(equalToConstant: Layout.containerHeight) + ] + NSLayoutConstraint.activate(positioningConstraints) + } + + private func centerContentContainer() { + NSLayoutConstraint.deactivate(positioningConstraints) + positioningConstraints = [ + contentContainerView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + contentContainerView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + contentContainerView.widthAnchor.constraint(equalToConstant: Layout.containerWidth), + contentContainerView.heightAnchor.constraint(equalToConstant: Layout.containerHeight) + ] + + NSLayoutConstraint.activate(positioningConstraints) + } + + private func createColorButton(for category: StarCategory) -> UIButton { + let button = UIButton(type: .custom) + + let colorKey: String + let colorName: String + switch category { + case .orange: + colorKey = "unreadBadgeOrangeColor" + colorName = "Orange" + case .red: + colorKey = "unreadBadgeRedColor" + colorName = "Red" + case .yellow: + colorKey = "unreadBadgeYellowColor" + colorName = "Yellow" + case .cyan: + colorKey = "unreadBadgeCyanColor" + colorName = "Cyan" + case .green: + colorKey = "unreadBadgeGreenColor" + colorName = "Green" + case .purple: + colorKey = "unreadBadgePurpleColor" + colorName = "Purple" + case .none: + colorKey = "unreadBadgeBlueColor" + colorName = "Blue" + } + + if let color = theme[uicolor: colorKey] { + button.backgroundColor = color + } + + button.layer.cornerRadius = Layout.colorButtonSize / 2 + button.clipsToBounds = false + + let isSelected = { + if case .starCategory(let currentCategory) = currentFilter { + return currentCategory == category + } + return false + }() + + button.accessibilityLabel = String(format: LocalizedString("bookmarks.filter.star.accessibility-label"), colorName) + button.accessibilityHint = isSelected + ? LocalizedString("bookmarks.filter.star.accessibility-hint.selected") + : LocalizedString("bookmarks.filter.star.accessibility-hint.unselected") + button.accessibilityTraits = isSelected ? [.button, .selected] : .button + + if isSelected { + button.layer.borderWidth = 3 + button.layer.borderColor = theme[uicolor: "tintColor"]?.cgColor ?? UIColor.systemBlue.cgColor + button.layer.shadowColor = theme[uicolor: "tintColor"]?.cgColor ?? UIColor.systemBlue.cgColor + button.layer.shadowRadius = 4 + button.layer.shadowOpacity = 0.3 + button.layer.shadowOffset = .zero + } else { + button.layer.borderWidth = 0 + button.layer.shadowOpacity = 0 + } + + button.addTarget(self, action: #selector(colorButtonTouchDown(_:)), for: .touchDown) + button.addTarget(self, action: #selector(colorButtonTouchUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + button.addTarget(self, action: #selector(colorButtonTapped(_:)), for: .touchUpInside) + button.tag = Int(category.rawValue) + + return button + } + + @objc private func colorButtonTouchDown(_ sender: UIButton) { + if UIAccessibility.isReduceMotionEnabled { + UIView.animate(withDuration: 0.1) { + sender.alpha = 0.7 + } + } else { + UIView.animate( + withDuration: 0.1, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction], + animations: { + sender.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + } + ) + } + + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + + @objc private func colorButtonTouchUp(_ sender: UIButton) { + if UIAccessibility.isReduceMotionEnabled { + UIView.animate(withDuration: 0.2) { + sender.alpha = 1.0 + } + } else { + UIView.animate( + withDuration: 0.3, + delay: 0, + usingSpringWithDamping: 0.6, + initialSpringVelocity: 0.5, + options: [.curveEaseOut, .allowUserInteraction], + animations: { + sender.transform = .identity + } + ) + } + } + + @objc private func colorButtonTapped(_ sender: UIButton) { + let category = StarCategory(rawValue: Int16(sender.tag)) ?? .orange + + if case .starCategory(let currentCategory) = currentFilter, currentCategory == category { + currentFilter = .all + onFilterSelected(.all) + if enableHaptics { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } else { + let filter = BookmarkFilter.starCategory(category) + currentFilter = filter + segmentedControl.selectedSegmentIndex = UISegmentedControl.noSegment + onFilterSelected(filter) + if enableHaptics { + UISelectionFeedbackGenerator().selectionChanged() + } + } + + UIView.animate(withDuration: 0.2) { [weak self] in + self?.updateFilterUI() + } + } + + @objc private func segmentChanged() { + if enableHaptics { + UISelectionFeedbackGenerator().selectionChanged() + } + + let filter: BookmarkFilter + switch segmentedControl.selectedSegmentIndex { + case 0: filter = .all + case 1: filter = .unreadOnly + case 2: filter = .readOnly + default: filter = .all + } + + currentFilter = filter + onFilterSelected(filter) + + UIView.animate(withDuration: 0.2) { [weak self] in + self?.updateFilterUI() + } + } + + private func updateFilterUI() { + switch currentFilter { + case .all: segmentedControl.selectedSegmentIndex = 0 + case .unreadOnly: segmentedControl.selectedSegmentIndex = 1 + case .readOnly: segmentedControl.selectedSegmentIndex = 2 + case .starCategory(_): segmentedControl.selectedSegmentIndex = UISegmentedControl.noSegment + default: segmentedControl.selectedSegmentIndex = 0 + } + + for button in colorButtons { + let category = StarCategory(rawValue: Int16(button.tag)) ?? .orange + + let isSelected = { + if case .starCategory(let currentCategory) = currentFilter { + return currentCategory == category + } + return false + }() + + let colorName = { + switch category { + case .orange: return "Orange" + case .red: return "Red" + case .yellow: return "Yellow" + case .cyan: return "Cyan" + case .green: return "Green" + case .purple: return "Purple" + case .none: return "Blue" + } + }() + + button.accessibilityLabel = String(format: LocalizedString("bookmarks.filter.star.accessibility-label"), colorName) + button.accessibilityHint = isSelected + ? LocalizedString("bookmarks.filter.star.accessibility-hint.selected") + : LocalizedString("bookmarks.filter.star.accessibility-hint.unselected") + button.accessibilityTraits = isSelected ? [.button, .selected] : .button + + UIView.animate( + withDuration: 0.3, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0, + options: [.curveEaseInOut], + animations: { [weak self] in + guard let self = self else { return } + if isSelected { + button.layer.borderWidth = 3 + button.layer.borderColor = self.theme[uicolor: "tintColor"]?.cgColor ?? UIColor.systemBlue.cgColor + button.layer.shadowColor = self.theme[uicolor: "tintColor"]?.cgColor ?? UIColor.systemBlue.cgColor + button.layer.shadowRadius = 4 + button.layer.shadowOpacity = 0.3 + button.transform = CGAffineTransform(scaleX: 1.1, y: 1.1) + } else { + button.layer.borderWidth = 0 + button.layer.shadowOpacity = 0 + button.transform = .identity + } + } + ) + } + } +} + final class BookmarksTableViewController: TableViewController { private var cancellables: Set = [] @@ -25,6 +584,16 @@ final class BookmarksTableViewController: TableViewController { @FoilDefaultStorage(Settings.showThreadTags) private var showThreadTags @FoilDefaultStorage(Settings.bookmarksSortedUnread) private var sortUnreadToTop + private var currentFilter: BookmarkFilter = .all + private var filterButton: UIBarButtonItem! + private var filterButtonView: UIButton! + private var searchButton: UIBarButtonItem! + private var searchButtonView: UIButton! + private var searchBar: UISearchBar! + private var searchBarContainerView: UIView! + private var filterPopoverController: UIViewController? + + private lazy var multiplexer: ScrollViewDelegateMultiplexer = { return ScrollViewDelegateMultiplexer(scrollView: tableView) }() @@ -38,11 +607,247 @@ final class BookmarksTableViewController: TableViewController { tabBarItem.image = UIImage(named: "bookmarks") tabBarItem.selectedImage = UIImage(named: "bookmarks-filled") - navigationItem.rightBarButtonItem = editButtonItem + + setupFilterButton() + setupSearchButton() + setupSearchBar() + navigationItem.leftBarButtonItem = editButtonItem + navigationItem.rightBarButtonItems = [filterButton, searchButton] themeDidChange() } + private func setupFilterButton() { + filterButtonView = UIButton(type: .system) + filterButtonView.setImage(UIImage(systemName: "line.3.horizontal.decrease"), for: .normal) + filterButtonView.accessibilityLabel = LocalizedString("bookmarks.filter.button.accessibility-label") + filterButtonView.addTarget(self, action: #selector(filterButtonTapped), for: .touchUpInside) + + if #available(iOS 26.0, *) { + filterButtonView.tintAdjustmentMode = .normal + } + + filterButton = UIBarButtonItem(customView: filterButtonView) + } + + private func setupSearchButton() { + searchButtonView = UIButton(type: .system) + searchButtonView.setImage(UIImage(named: "quick-look"), for: .normal) + searchButtonView.accessibilityLabel = LocalizedString("bookmarks.search.button.accessibility-label") + searchButtonView.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside) + + if #available(iOS 26.0, *) { + searchButtonView.tintAdjustmentMode = .normal + } + + searchButton = UIBarButtonItem(customView: searchButtonView) + } + + private func setupSearchBar() { + searchBar = UISearchBar() + searchBar.delegate = self + searchBar.placeholder = LocalizedString("bookmarks.search.placeholder") + searchBar.showsCancelButton = true + searchBar.searchBarStyle = .minimal + searchBar.isHidden = true + + searchBarContainerView = UIView() + searchBarContainerView.addSubview(searchBar) + + searchBar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: searchBarContainerView.topAnchor, constant: 8), + searchBar.leadingAnchor.constraint(equalTo: searchBarContainerView.leadingAnchor, constant: 16), + searchBar.trailingAnchor.constraint(equalTo: searchBarContainerView.trailingAnchor, constant: -16), + searchBar.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + @objc private func filterButtonTapped() { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + if let existingPopover = filterPopoverController { + existingPopover.dismiss(animated: true) { [weak self] in + self?.filterPopoverController = nil + self?.updateButtonColors() + } + return + } + + let filterMenuVC = FilterMenuViewController( + currentFilter: currentFilter, + theme: theme, + enableHaptics: enableHaptics, + onFilterSelected: { [weak self] filter in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.filterPopoverController?.dismiss(animated: true) { [weak self] in + self?.filterPopoverController = nil + self?.updateButtonColors() + } + } + self?.applyFilter(filter) + } + ) + + filterMenuVC.onDismiss = { [weak self] in + self?.filterPopoverController = nil + self?.updateButtonColors() + } + + filterMenuVC.modalPresentationStyle = .overCurrentContext + filterMenuVC.modalTransitionStyle = .crossDissolve + filterMenuVC.presentationController?.delegate = self + + if filterButtonView.superview != nil { + let buttonFrameInView = view.convert(filterButtonView.bounds, from: filterButtonView) + filterMenuVC.setSourceButtonRect(buttonFrameInView) + } + + filterPopoverController = filterMenuVC + updateButtonColors() + present(filterMenuVC, animated: false) + } + + @objc private func searchButtonTapped() { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + toggleSearchBar() + } + + private func toggleSearchBar() { + let isSearchVisible = searchBarContainerView.frame.height > 0 + + if !isSearchVisible { + searchBar.isHidden = false + searchBar.alpha = 0 + + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) + tableView.tableHeaderView = searchBarContainerView + + // Briefly set non-zero height so updateButtonColors detects search as visible + searchBarContainerView.frame.size.height = 52 + updateButtonColors() + searchBarContainerView.frame.size.height = 0 + + UIView.animate( + withDuration: 0.4, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.3, + options: [.curveEaseInOut], + animations: { + self.searchBarContainerView.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 52) + self.tableView.tableHeaderView = self.searchBarContainerView + self.searchBar.alpha = 1.0 + }, + completion: { _ in + self.searchBar.becomeFirstResponder() + } + ) + } else { + searchBar.resignFirstResponder() + searchBar.text = "" + applyFilter(.all) + + // Briefly set zero height so updateButtonColors detects search as hidden + let originalHeight = searchBarContainerView.frame.height + searchBarContainerView.frame.size.height = 0 + updateButtonColors() + searchBarContainerView.frame.size.height = originalHeight + + UIView.animate( + withDuration: 0.3, + delay: 0, + usingSpringWithDamping: 0.9, + initialSpringVelocity: 0.1, + options: [.curveEaseInOut], + animations: { + self.searchBarContainerView.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0) + self.tableView.tableHeaderView = self.searchBarContainerView + self.searchBar.alpha = 0.0 + }, + completion: { _ in + self.searchBar.isHidden = true + } + ) + } + } + + private func applyFilter(_ filter: BookmarkFilter) { + currentFilter = filter + dataSource = makeDataSource() + tableView.reloadData() + + updateButtonColors() + } + + private func updateButtonColors() { + let isFilterActive: Bool + switch currentFilter { + case .starCategory(_), .unreadOnly, .readOnly: + isFilterActive = true + case .all: + isFilterActive = false + case .textSearch(_): + isFilterActive = true + } + + let isSearchVisible = searchBarContainerView.frame.height > 0 + let isFilterMenuOpen = filterPopoverController != nil + + if #available(iOS 26.0, *) { + // Explicit tint color prevents system default blue when NavigationController sets tintColor = nil + let buttonTintColor = theme["mode"] == "dark" ? UIColor.white : UIColor.black + + filterButton?.tintColor = buttonTintColor + filterButtonView?.tintColor = buttonTintColor + searchButton?.tintColor = buttonTintColor + searchButtonView?.tintColor = buttonTintColor + + filterButton?.customView?.alpha = (isFilterActive || isFilterMenuOpen) ? 0.5 : 1.0 + filterButtonView?.alpha = (isFilterActive || isFilterMenuOpen) ? 0.5 : 1.0 + + searchButton?.customView?.alpha = isSearchVisible ? 0.5 : 1.0 + searchButtonView?.alpha = isSearchVisible ? 0.5 : 1.0 + } else { + filterButton?.customView?.alpha = 1.0 + filterButtonView?.alpha = 1.0 + searchButton?.customView?.alpha = 1.0 + searchButtonView?.alpha = 1.0 + + let selectedColor = theme[uicolor: "tintColor"] ?? theme[uicolor: "navigationBarTextColor"] + let normalColor = theme[uicolor: "navigationBarTextColor"] + + let filterColor = (isFilterActive || isFilterMenuOpen) ? selectedColor : normalColor + + let searchColor = isSearchVisible ? selectedColor : normalColor + + filterButton?.tintColor = filterColor + filterButtonView?.tintColor = filterColor + + if let buttonView = filterButtonView, + let currentImage = buttonView.currentImage, + let color = filterColor { + let tintedImage = currentImage.withTintColor(color, renderingMode: .alwaysTemplate) + buttonView.setImage(tintedImage, for: .normal) + } + + searchButton?.tintColor = searchColor + searchButtonView?.tintColor = searchColor + + if let buttonView = searchButtonView, + let currentImage = buttonView.currentImage, + let color = searchColor { + let tintedImage = currentImage.withTintColor(color, renderingMode: .alwaysTemplate) + buttonView.setImage(tintedImage, for: .normal) + } + } + } + deinit { if isViewLoaded { multiplexer.removeDelegate(self) @@ -53,6 +858,7 @@ final class BookmarksTableViewController: TableViewController { let dataSource = try! ThreadListDataSource( bookmarksSortedByUnread: sortUnreadToTop, showsTagAndRating: showThreadTags, + filter: currentFilter, managedObjectContext: managedObjectContext, tableView: tableView ) @@ -70,19 +876,27 @@ final class BookmarksTableViewController: TableViewController { latestPage = page RefreshMinder.sharedMinder.didRefresh(.bookmarks) - if threads.count >= 40 { - enableLoadMore() - } else { - disableLoadMore() + await MainActor.run { + stopAnimatingPullToRefresh() + + if threads.count >= 40 { + enableLoadMore() + } else { + disableLoadMore() + } + + loadMoreFooter?.didFinish() } } catch { - if visible { - let alert = UIAlertController(networkError: error) - present(alert, animated: true) + await MainActor.run { + if visible { + let alert = UIAlertController(networkError: error) + present(alert, animated: true) + } + stopAnimatingPullToRefresh() + loadMoreFooter?.didFinish() } } - stopAnimatingPullToRefresh() - loadMoreFooter?.didFinish() } } @@ -109,6 +923,10 @@ final class BookmarksTableViewController: TableViewController { tableView.hideExtraneousSeparators() tableView.restorationIdentifier = "Bookmarks table" + + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) + searchBarContainerView.clipsToBounds = true + tableView.tableHeaderView = searchBarContainerView dataSource = makeDataSource() tableView.reloadData() @@ -147,8 +965,33 @@ final class BookmarksTableViewController: TableViewController { super.themeDidChange() loadMoreFooter?.themeDidChange() + + if #available(iOS 26.0, *) { + } else { + editButtonItem.tintColor = theme[uicolor: "navigationBarTextColor"] + } + updateButtonColors() + + if let searchBar = searchBar { + searchBarContainerView.backgroundColor = theme["listBackgroundColor"] + searchBar.barTintColor = theme["listBackgroundColor"] + searchBar.backgroundColor = theme["listBackgroundColor"] + searchBar.searchTextField.backgroundColor = theme["navigationBarBackgroundColor"] + searchBar.searchTextField.textColor = theme["listTextColor"] + searchBar.tintColor = theme["tintColor"] + } + + let themeMode = theme[string: "mode"] + let userInterfaceStyle: UIUserInterfaceStyle = themeMode == "light" ? .light : .dark + + overrideUserInterfaceStyle = userInterfaceStyle + tableView.overrideUserInterfaceStyle = userInterfaceStyle + view.overrideUserInterfaceStyle = userInterfaceStyle + + filterButtonView?.overrideUserInterfaceStyle = userInterfaceStyle tableView.separatorColor = theme["listSeparatorColor"] + tableView.separatorInset.left = ThreadListCell.separatorLeftInset( showsTagAndRating: showThreadTags, inTableWithWidth: tableView.bounds.width @@ -185,6 +1028,22 @@ final class BookmarksTableViewController: TableViewController { // MARK: Actions private func refresh() { + // Complete any in-progress search bar animations before refreshing + if !searchBar.isHidden && searchBarContainerView.frame.height != 52 { + searchBarContainerView.layer.removeAllAnimations() + searchBar.layer.removeAllAnimations() + + if searchBar.text?.isEmpty == false { + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + searchBar.alpha = 1.0 + } else { + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) + searchBar.alpha = 0.0 + searchBar.isHidden = true + } + tableView.tableHeaderView = searchBarContainerView + } + startAnimatingPullToRefresh() loadPage(page: 1) } @@ -287,15 +1146,11 @@ extension BookmarksTableViewController { } return .none } - - override func tableView( - _ tableView: UITableView, - contextMenuConfigurationForRowAt indexPath: IndexPath, - point: CGPoint - ) -> UIContextMenuConfiguration? { + + override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration.makeFromThreadList( for: dataSource!.thread(at: indexPath), - presenter: self + presenter: self ) if #available(iOS 16.0, *) { configuration.preferredMenuElementOrder = .fixed @@ -309,3 +1164,36 @@ extension BookmarksTableViewController: ThreadListDataSourceDeletionDelegate { setThread(thread, isBookmarked: false) } } + +extension BookmarksTableViewController: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + let trimmedText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedText.isEmpty { + applyFilter(.all) + } else { + applyFilter(.textSearch(trimmedText)) + } + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + toggleSearchBar() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } +} + +extension BookmarksTableViewController: UIPopoverPresentationControllerDelegate, UIAdaptivePresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + .none + } + + func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + filterPopoverController = nil + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + filterPopoverController = nil + } +} From fb1f09c6ff0b74b9df8e1c4af656c60c586f5db0 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:50:28 +1000 Subject: [PATCH 2/2] Final cleanup --- App/Data Sources/ThreadListDataSource.swift | 4 - .../BookmarksTableViewController.swift | 103 ++++++++---------- 2 files changed, 43 insertions(+), 64 deletions(-) diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index b36dcef1a..ba8bf21aa 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -64,10 +64,6 @@ final class ThreadListDataSource: NSObject { try self.init(managedObjectContext: managedObjectContext, fetchRequest: fetchRequest, tableView: tableView, ignoreSticky: true, showsTagAndRating: showsTagAndRating, placeholder: .thread(tintColor: nil)) } - - convenience init(bookmarksSortedByUnread sortedByUnread: Bool, showsTagAndRating: Bool, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { - try self.init(bookmarksSortedByUnread: sortedByUnread, showsTagAndRating: showsTagAndRating, filter: .all, managedObjectContext: managedObjectContext, tableView: tableView) - } convenience init(forum: Forum, sortedByUnread: Bool, showsTagAndRating: Bool, threadTagFilter: Set, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { let fetchRequest = AwfulThread.makeFetchRequest() diff --git a/App/View Controllers/Threads/BookmarksTableViewController.swift b/App/View Controllers/Threads/BookmarksTableViewController.swift index 815a5f67c..e47ecf9a9 100644 --- a/App/View Controllers/Threads/BookmarksTableViewController.swift +++ b/App/View Controllers/Threads/BookmarksTableViewController.swift @@ -54,7 +54,6 @@ private class FilterMenuViewController: UIViewController { return view }() private var sourceButtonRect: CGRect? - private var sourceButtonSuperview: UIView? private var positioningConstraints: [NSLayoutConstraint] = [] private var colorButtons: [UIButton] = [] @@ -85,7 +84,6 @@ private class FilterMenuViewController: UIViewController { func setSourceButtonRect(_ rect: CGRect) { sourceButtonRect = rect - sourceButtonSuperview = view } required init?(coder: NSCoder) { @@ -317,20 +315,13 @@ private class FilterMenuViewController: UIViewController { return } - let convertedRect: CGRect - if let sourceSuperview = sourceButtonSuperview, sourceSuperview != view { - convertedRect = view.convert(sourceRect, from: sourceSuperview) - } else { - convertedRect = sourceRect - } - - let containerX = max(16, min(convertedRect.maxX - Layout.containerWidth, view.bounds.width - Layout.containerWidth - 16)) + let containerX = max(16, min(sourceRect.maxX - Layout.containerWidth, view.bounds.width - Layout.containerWidth - 16)) let navBarBottom = navigationController?.navigationBar.frame.maxY ?? 100 let safeAreaTop = view.safeAreaInsets.top let containerY = max(navBarBottom + 8, safeAreaTop + 8) let finalY = (containerY + Layout.containerHeight > view.bounds.height - 44) - ? convertedRect.minY - Layout.containerHeight - 8 + ? sourceRect.minY - Layout.containerHeight - 8 : containerY NSLayoutConstraint.deactivate(positioningConstraints) @@ -466,7 +457,7 @@ private class FilterMenuViewController: UIViewController { @objc private func colorButtonTapped(_ sender: UIButton) { let category = StarCategory(rawValue: Int16(sender.tag)) ?? .orange - + if case .starCategory(let currentCategory) = currentFilter, currentCategory == category { currentFilter = .all onFilterSelected(.all) @@ -482,17 +473,15 @@ private class FilterMenuViewController: UIViewController { UISelectionFeedbackGenerator().selectionChanged() } } - - UIView.animate(withDuration: 0.2) { [weak self] in - self?.updateFilterUI() - } + + animateSelectionThenDismiss() } @objc private func segmentChanged() { if enableHaptics { UISelectionFeedbackGenerator().selectionChanged() } - + let filter: BookmarkFilter switch segmentedControl.selectedSegmentIndex { case 0: filter = .all @@ -500,13 +489,25 @@ private class FilterMenuViewController: UIViewController { case 2: filter = .readOnly default: filter = .all } - + currentFilter = filter onFilterSelected(filter) - - UIView.animate(withDuration: 0.2) { [weak self] in - self?.updateFilterUI() - } + + animateSelectionThenDismiss() + } + + private func animateSelectionThenDismiss() { + UIView.animate( + withDuration: 0.2, + animations: { [weak self] in + self?.updateFilterUI() + }, + completion: { [weak self] _ in + self?.dismiss(animated: true) { [weak self] in + self?.onDismiss?() + } + } + ) } private func updateFilterUI() { @@ -592,6 +593,7 @@ final class BookmarksTableViewController: TableViewController { private var searchButtonView: UIButton! private var searchBar: UISearchBar! private var searchBarContainerView: UIView! + private var isSearchVisible = false private var filterPopoverController: UIViewController? @@ -682,16 +684,10 @@ final class BookmarksTableViewController: TableViewController { theme: theme, enableHaptics: enableHaptics, onFilterSelected: { [weak self] filter in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self?.filterPopoverController?.dismiss(animated: true) { [weak self] in - self?.filterPopoverController = nil - self?.updateButtonColors() - } - } self?.applyFilter(filter) } ) - + filterMenuVC.onDismiss = { [weak self] in self?.filterPopoverController = nil self?.updateButtonColors() @@ -720,19 +716,14 @@ final class BookmarksTableViewController: TableViewController { } private func toggleSearchBar() { - let isSearchVisible = searchBarContainerView.frame.height > 0 - if !isSearchVisible { + isSearchVisible = true searchBar.isHidden = false searchBar.alpha = 0 searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) tableView.tableHeaderView = searchBarContainerView - - // Briefly set non-zero height so updateButtonColors detects search as visible - searchBarContainerView.frame.size.height = 52 updateButtonColors() - searchBarContainerView.frame.size.height = 0 UIView.animate( withDuration: 0.4, @@ -750,16 +741,11 @@ final class BookmarksTableViewController: TableViewController { } ) } else { + isSearchVisible = false searchBar.resignFirstResponder() searchBar.text = "" applyFilter(.all) - // Briefly set zero height so updateButtonColors detects search as hidden - let originalHeight = searchBarContainerView.frame.height - searchBarContainerView.frame.size.height = 0 - updateButtonColors() - searchBarContainerView.frame.size.height = originalHeight - UIView.animate( withDuration: 0.3, delay: 0, @@ -797,7 +783,6 @@ final class BookmarksTableViewController: TableViewController { isFilterActive = true } - let isSearchVisible = searchBarContainerView.frame.height > 0 let isFilterMenuOpen = filterPopoverController != nil if #available(iOS 26.0, *) { @@ -951,14 +936,12 @@ final class BookmarksTableViewController: TableViewController { } override func setEditing(_ editing: Bool, animated: Bool) { - // Takes care of toggling the button's title. super.setEditing(editing, animated: true) if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - - // Toggle table view editing. + tableView.setEditing(editing, animated: true) } @@ -976,9 +959,8 @@ final class BookmarksTableViewController: TableViewController { super.themeDidChange() loadMoreFooter?.themeDidChange() - - if #available(iOS 26.0, *) { - } else { + + if #unavailable(iOS 26.0) { editButtonItem.tintColor = theme[uicolor: "navigationBarTextColor"] } updateButtonColors() @@ -1039,22 +1021,23 @@ final class BookmarksTableViewController: TableViewController { // MARK: Actions private func refresh() { - // Complete any in-progress search bar animations before refreshing - if !searchBar.isHidden && searchBarContainerView.frame.height != 52 { + // Snap any in-flight search bar animation to its terminal state so + // pull-to-refresh doesn't fight the spring animation. + if isSearchVisible, searchBarContainerView.frame.height != 52 { searchBarContainerView.layer.removeAllAnimations() searchBar.layer.removeAllAnimations() - - if searchBar.text?.isEmpty == false { - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) - searchBar.alpha = 1.0 - } else { - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) - searchBar.alpha = 0.0 - searchBar.isHidden = true - } + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + searchBar.alpha = 1.0 + tableView.tableHeaderView = searchBarContainerView + } else if !isSearchVisible, !searchBar.isHidden { + searchBarContainerView.layer.removeAllAnimations() + searchBar.layer.removeAllAnimations() + searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) + searchBar.alpha = 0.0 + searchBar.isHidden = true tableView.tableHeaderView = searchBarContainerView } - + startAnimatingPullToRefresh() loadPage(page: 1) }