diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift
index 66d952b7d..5caac7432 100644
--- a/Mac/AppDefaults.swift
+++ b/Mac/AppDefaults.swift
@@ -44,6 +44,7 @@ final class AppDefaults {
static let currentThemeName = "currentThemeName"
static let hasSeenNotAllArticlesHaveURLsAlert = "hasSeenNotAllArticlesHaveURLsAlert"
static let twitterDeprecationAlertShown = "twitterDeprecationAlertShown"
+ static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll"
// Hidden prefs
static let showDebugMenu = "ShowDebugMenu"
@@ -329,6 +330,14 @@ final class AppDefaults {
}
}
+ var markArticlesAsReadOnScroll: Bool {
+ get {
+ return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll)
+ }
+ set {
+ AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue)
+ }
+ }
func registerDefaults() {
#if DEBUG
diff --git a/Mac/Base.lproj/Preferences.storyboard b/Mac/Base.lproj/Preferences.storyboard
index e8cd72511..ec3c0ba73 100644
--- a/Mac/Base.lproj/Preferences.storyboard
+++ b/Mac/Base.lproj/Preferences.storyboard
@@ -32,22 +32,30 @@
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
-
+
@@ -76,7 +84,7 @@
-
+
@@ -91,7 +99,7 @@
-
+
-
-
+
+
-
+
@@ -127,7 +135,7 @@
-
+
@@ -155,18 +163,18 @@
-
+
-
-
+
+
-
+
@@ -201,15 +209,15 @@
-
-
+
+
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
+
-
+
+
-
-
-
+
@@ -427,35 +455,40 @@
-
-
-
+
-
+
+
+
+
+
+
-
+
+
+
-
+
@@ -475,7 +508,6 @@
-
@@ -495,16 +527,16 @@
-
+
-
+
-
+
-
+
@@ -579,7 +611,7 @@
-
+
@@ -611,7 +643,7 @@
-
+
@@ -642,7 +674,7 @@
-
+
@@ -666,16 +698,16 @@
-
+
-
+
-
+
-
+
@@ -744,7 +776,7 @@
-
+
@@ -778,7 +810,7 @@
-
+
@@ -809,7 +841,7 @@
-
+
diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift
index 30ce24961..c348af5bd 100644
--- a/Mac/MainWindow/Timeline/TimelineViewController.swift
+++ b/Mac/MainWindow/Timeline/TimelineViewController.swift
@@ -124,6 +124,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
}
directlyMarkedAsUnreadArticles = Set()
+ lastVerticlePosition = 0
articleRowMap = [String: [Int]]()
tableView.reloadData()
}
@@ -194,6 +195,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
private let keyboardDelegate = TimelineKeyboardDelegate()
private var timelineShowsSeparatorsObserver: NSKeyValueObservation?
+ private var markAsReadOnScrollWorkItem: DispatchWorkItem?
+ private var markAsReadOnScrollStart: Int?
+ private var markAsReadOnScrollEnd: Int?
+ private var lastVerticlePosition: CGFloat = 0
+
convenience init(delegate: TimelineDelegate) {
self.init(nibName: "TimelineTableView", bundle: nil)
self.delegate = delegate
@@ -224,6 +230,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidDirectMarking(_:)), name: .MarkStatusCommandDidDirectMarking, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidUndoDirectMarking(_:)), name: .MarkStatusCommandDidUndoDirectMarking, object: nil)
+ NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidScroll), name: NSScrollView.didLiveScrollNotification, object: tableView.enclosingScrollView)
didRegisterForNotifications = true
}
}
@@ -235,6 +242,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
// MARK: - API
func markAllAsRead(completion: (() -> Void)? = nil) {
+ markAllAsRead(articles, completion: completion)
+ }
+
+ func markAllAsRead(_ articles: [Article], completion: (() -> Void)? = nil) {
let markableArticles = Set(articles).subtracting(directlyMarkedAsUnreadArticles)
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
@@ -329,6 +340,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
let urlStrings = selectedArticles.compactMap { $0.preferredLink }
Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
+
+ @objc func scrollViewDidScroll(notification: Notification) {
+ markAsReadOnScroll()
+ }
@IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) {
guard !selectedArticles.isEmpty else {
@@ -1326,4 +1341,51 @@ private extension TimelineViewController {
}
return false
}
+
+ func markAsReadOnScroll() {
+ guard AppDefaults.shared.markArticlesAsReadOnScroll else { return }
+
+ // Only try to mark if we are scrolling up
+ defer {
+ lastVerticlePosition = tableView.enclosingScrollView?.documentVisibleRect.origin.y ?? 0
+ }
+ guard lastVerticlePosition < tableView.enclosingScrollView?.documentVisibleRect.origin.y ?? 0 else {
+ return
+ }
+
+ // Make sure we are a little past the visible area so that marking isn't too touchy
+ let firstVisibleRowIndex = tableView.rows(in: tableView.visibleRect).location
+ guard let firstVisibleRowRect = tableView.rowView(atRow: firstVisibleRowIndex, makeIfNecessary: false)?.frame,
+ tableView.convert(firstVisibleRowRect, to: tableView.enclosingScrollView).origin.y < tableView.safeAreaInsets.top - 20 else {
+ return
+ }
+
+ // We only mark immediately after scrolling stops, not during, to prevent scroll hitching
+ markAsReadOnScrollWorkItem?.cancel()
+ markAsReadOnScrollWorkItem = DispatchWorkItem { [weak self] in
+ defer {
+ self?.markAsReadOnScrollStart = nil
+ self?.markAsReadOnScrollEnd = nil
+ }
+
+ guard let start: Int = self?.markAsReadOnScrollStart,
+ let end: Int = self?.markAsReadOnScrollEnd ?? self?.markAsReadOnScrollStart,
+ start <= end,
+ let self = self else {
+ return
+ }
+
+ let articles = self.articles[start...end].filter({ $0.status.read == false })
+ self.markAllAsRead(articles)
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: markAsReadOnScrollWorkItem!)
+
+ // Here we are creating a range of rows to attempt to mark later with the work item
+ guard markAsReadOnScrollStart != nil else {
+ markAsReadOnScrollStart = max(firstVisibleRowIndex - 5, 0)
+ return
+ }
+ markAsReadOnScrollEnd = max(markAsReadOnScrollEnd ?? 0, firstVisibleRowIndex)
+ }
+
}
diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift
index 91c355853..604500269 100644
--- a/iOS/AppDefaults.swift
+++ b/iOS/AppDefaults.swift
@@ -59,6 +59,7 @@ final class AppDefaults {
static let useSystemBrowser = "useSystemBrowser"
static let currentThemeName = "currentThemeName"
static let twitterDeprecationAlertShown = "twitterDeprecationAlertShown"
+ static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll"
}
let isDeveloperBuild: Bool = {
@@ -233,6 +234,15 @@ final class AppDefaults {
}
}
+ var markArticlesAsReadOnScroll: Bool {
+ get {
+ return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll)
+ }
+ set {
+ AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue)
+ }
+ }
+
static func registerDefaults() {
let defaults: [String : Any] = [Key.userInterfaceColorPalette: UserInterfaceColorPalette.automatic.rawValue,
Key.timelineGroupByFeed: false,
diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift
index 15f974941..1fbf7fd85 100644
--- a/iOS/MasterTimeline/MasterTimelineViewController.swift
+++ b/iOS/MasterTimeline/MasterTimelineViewController.swift
@@ -31,11 +31,15 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
private lazy var dataSource = makeDataSource()
private let searchController = UISearchController(searchResultsController: nil)
+ private var markAsReadOnScrollWorkItem: DispatchWorkItem?
+ private var markAsReadOnScrollStart: Int?
+ private var markAsReadOnScrollEnd: Int?
+ private var lastVerticlePosition: CGFloat = 0
+
var mainControllerIdentifier = MainControllerIdentifier.masterTimeline
weak var coordinator: SceneCoordinator!
var undoableCommands = [UndoableCommand]()
- let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0)
private let keyboardManager = KeyboardManager(type: .timeline)
override var keyCommands: [UIKeyCommand]? {
@@ -434,7 +438,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
- scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
+ coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow()
+ markAsReadOnScroll()
}
// MARK: Notifications
@@ -530,10 +535,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
updateUI()
}
- @objc func scrollPositionDidChange() {
- coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow()
- }
-
// MARK: Reloading
func queueReloadAvailableCells() {
@@ -695,6 +696,8 @@ private extension MasterTimelineViewController {
}
func applyChanges(animated: Bool, completion: (() -> Void)? = nil) {
+ lastVerticlePosition = 0
+
if coordinator.articles.count == 0 {
tableView.rowHeight = tableView.estimatedRowHeight
} else {
@@ -723,7 +726,6 @@ private extension MasterTimelineViewController {
}
func configure(_ cell: MasterTimelineTableViewCell, article: Article, indexPath: IndexPath) {
-
let iconImage = iconImageFor(article)
let featuredImage = featuredImageFor(article)
@@ -748,6 +750,54 @@ private extension MasterTimelineViewController {
return nil
}
+ func markAsReadOnScroll() {
+ // Only try to mark if we are scrolling up
+ defer {
+ lastVerticlePosition = tableView.contentOffset.y
+ }
+ guard lastVerticlePosition < tableView.contentOffset.y else {
+ return
+ }
+
+ // Implement Mark As Read on Scroll where we mark after the leading edge goes a little beyond the safe area inset
+ guard AppDefaults.shared.markArticlesAsReadOnScroll,
+ lastVerticlePosition < tableView.contentOffset.y,
+ let firstVisibleIndexPath = tableView.indexPathsForVisibleRows?.first else { return }
+
+ let firstVisibleRowRect = tableView.rectForRow(at: firstVisibleIndexPath)
+ guard tableView.convert(firstVisibleRowRect, to: nil).origin.y < tableView.safeAreaInsets.top - 20 else { return }
+
+ // We only mark immediately after scrolling stops, not during, to prevent scroll hitching
+ markAsReadOnScrollWorkItem?.cancel()
+ markAsReadOnScrollWorkItem = DispatchWorkItem { [weak self] in
+ defer {
+ self?.markAsReadOnScrollStart = nil
+ self?.markAsReadOnScrollEnd = nil
+ }
+
+ guard let start: Int = self?.markAsReadOnScrollStart,
+ let end: Int = self?.markAsReadOnScrollEnd ?? self?.markAsReadOnScrollStart,
+ start <= end,
+ let self = self else {
+ return
+ }
+
+ let articles = Array(start...end)
+ .map({ IndexPath(row: $0, section: 0) })
+ .compactMap({ self.dataSource.itemIdentifier(for: $0) })
+ .filter({ $0.status.read == false })
+ self.coordinator.markAllAsRead(articles)
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: markAsReadOnScrollWorkItem!)
+
+ // Here we are creating a range of rows to attempt to mark later with the work item
+ guard markAsReadOnScrollStart != nil else {
+ markAsReadOnScrollStart = max(firstVisibleIndexPath.row - 5, 0)
+ return
+ }
+ markAsReadOnScrollEnd = max(markAsReadOnScrollEnd ?? 0, firstVisibleIndexPath.row)
+ }
+
func toggleArticleReadStatusAction(_ article: Article) -> UIAction? {
guard !article.status.read || article.isAvailableToMarkUnread else { return nil }
diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift
index c46ee94d5..85f4febc5 100644
--- a/iOS/SceneCoordinator.swift
+++ b/iOS/SceneCoordinator.swift
@@ -110,7 +110,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging {
}
}
- private var directlyMarkedAsUnreadArticles = Set()
+ var directlyMarkedAsUnreadArticles = Set()
var prefersStatusBarHidden = false
diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard
index 68ff0e72c..9db7de044 100644
--- a/iOS/Settings/Settings.storyboard
+++ b/iOS/Settings/Settings.storyboard
@@ -254,9 +254,42 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -282,20 +315,20 @@
-
+
-
+
-
+
-
+
-
-
+
-
+
-
+
-
+
@@ -385,10 +418,10 @@
-
+
-
+
@@ -421,14 +454,14 @@
-
+
-
+
@@ -438,7 +471,7 @@
-
+
@@ -455,7 +488,7 @@
-
+
@@ -472,7 +505,7 @@
-
+
@@ -489,7 +522,7 @@
-
+
@@ -506,7 +539,7 @@
-
+
@@ -523,7 +556,7 @@
-
+
@@ -540,7 +573,7 @@
-
+
@@ -557,7 +590,7 @@
-
+
@@ -593,6 +626,7 @@
+
diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift
index 2112e62dd..aff4f26ea 100644
--- a/iOS/Settings/SettingsViewController.swift
+++ b/iOS/Settings/SettingsViewController.swift
@@ -22,9 +22,9 @@ class SettingsViewController: UITableViewController, Logging {
@IBOutlet weak var timelineSortOrderSwitch: UISwitch!
@IBOutlet weak var groupByFeedSwitch: UISwitch!
@IBOutlet weak var refreshClearsReadArticlesSwitch: UISwitch!
+ @IBOutlet weak var markArticlesAsReadOnScrollSwitch: UISwitch!
@IBOutlet weak var articleThemeDetailLabel: UILabel!
@IBOutlet weak var confirmMarkAllAsReadSwitch: UISwitch!
- @IBOutlet weak var showFullscreenArticlesSwitch: UISwitch!
@IBOutlet weak var colorPaletteDetailLabel: UILabel!
@IBOutlet weak var openLinksInNetNewsWire: UISwitch!
@@ -75,6 +75,12 @@ class SettingsViewController: UITableViewController, Logging {
refreshClearsReadArticlesSwitch.isOn = false
}
+ if AppDefaults.shared.markArticlesAsReadOnScroll {
+ markArticlesAsReadOnScrollSwitch.isOn = true
+ } else {
+ markArticlesAsReadOnScrollSwitch.isOn = false
+ }
+
articleThemeDetailLabel.text = ArticleThemesManager.shared.currentTheme.name
if AppDefaults.shared.confirmMarkAllAsRead {
@@ -227,7 +233,7 @@ class SettingsViewController: UITableViewController, Logging {
}
case 4:
switch indexPath.row {
- case 3:
+ case 4:
let timeline = UIStoryboard.settings.instantiateController(ofType: TimelineCustomizerViewController.self)
self.navigationController?.pushViewController(timeline, animated: true)
default:
@@ -331,6 +337,14 @@ class SettingsViewController: UITableViewController, Logging {
}
}
+ @IBAction func switchMarkArticlesAsReadOnScroll(_ sender: Any) {
+ if markArticlesAsReadOnScrollSwitch.isOn {
+ AppDefaults.shared.markArticlesAsReadOnScroll = true
+ } else {
+ AppDefaults.shared.markArticlesAsReadOnScroll = false
+ }
+ }
+
@IBAction func switchConfirmMarkAllAsRead(_ sender: Any) {
if confirmMarkAllAsReadSwitch.isOn {
AppDefaults.shared.confirmMarkAllAsRead = true