Update iOS with latest TimelineModel refactoring

This commit is contained in:
Maurice Parker 2020-07-25 06:20:21 -05:00
parent 7d7a018fe1
commit 882ebbea3e
7 changed files with 220 additions and 47 deletions

View File

@ -0,0 +1,121 @@
//
// ReplaySubject.swift
// CombineExt
//
// Created by Jasdev Singh on 13/04/2020.
// Copyright © 2020 Combine Community. All rights reserved.
//
#if canImport(Combine)
import Combine
/// A `ReplaySubject` is a subject that can buffer one or more values. It stores value events, up to its `bufferSize` in a
/// first-in-first-out manner and then replays it to
/// future subscribers and also forwards completion events.
///
/// The implementation borrows heavily from [Entwines](https://github.com/tcldr/Entwine/blob/b839c9fcc7466878d6a823677ce608da998b95b9/Sources/Entwine/Operators/ReplaySubject.swift).
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public final class ReplaySubject<Output, Failure: Error>: Subject {
public typealias Output = Output
public typealias Failure = Failure
private let bufferSize: Int
private var buffer = [Output]()
// Keeping track of all live subscriptions, so `send` events can be forwarded to them.
private var subscriptions = [Subscription<AnySubscriber<Output, Failure>>]()
private var completion: Subscribers.Completion<Failure>?
private var isActive: Bool { completion == nil }
/// Create a `ReplaySubject`, buffering up to `bufferSize` values and replaying them to new subscribers
/// - Parameter bufferSize: The maximum number of value events to buffer and replay to all future subscribers.
public init(bufferSize: Int) {
self.bufferSize = bufferSize
}
public func send(_ value: Output) {
guard isActive else { return }
buffer.append(value)
if buffer.count > bufferSize {
buffer.removeFirst()
}
subscriptions.forEach { $0.forwardValueToBuffer(value) }
}
public func send(completion: Subscribers.Completion<Failure>) {
guard isActive else { return }
self.completion = completion
subscriptions.forEach { $0.forwardCompletionToBuffer(completion) }
}
public func send(subscription: Combine.Subscription) {
subscription.request(.unlimited)
}
public func receive<Subscriber: Combine.Subscriber>(subscriber: Subscriber) where Failure == Subscriber.Failure, Output == Subscriber.Input {
let subscriberIdentifier = subscriber.combineIdentifier
let subscription = Subscription(downstream: AnySubscriber(subscriber)) { [weak self] in
guard let self = self,
let subscriptionIndex = self.subscriptions
.firstIndex(where: { $0.innerSubscriberIdentifier == subscriberIdentifier }) else { return }
self.subscriptions.remove(at: subscriptionIndex)
}
subscriptions.append(subscription)
subscriber.receive(subscription: subscription)
subscription.replay(buffer, completion: completion)
}
}
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension ReplaySubject {
final class Subscription<Downstream: Subscriber>: Combine.Subscription where Output == Downstream.Input, Failure == Downstream.Failure {
private var demandBuffer: DemandBuffer<Downstream>?
private var cancellationHandler: (() -> Void)?
fileprivate let innerSubscriberIdentifier: CombineIdentifier
init(downstream: Downstream, cancellationHandler: (() -> Void)?) {
self.demandBuffer = DemandBuffer(subscriber: downstream)
self.innerSubscriberIdentifier = downstream.combineIdentifier
self.cancellationHandler = cancellationHandler
}
func replay(_ buffer: [Output], completion: Subscribers.Completion<Failure>?) {
buffer.forEach(forwardValueToBuffer)
if let completion = completion {
forwardCompletionToBuffer(completion)
}
}
func forwardValueToBuffer(_ value: Output) {
_ = demandBuffer?.buffer(value: value)
}
func forwardCompletionToBuffer(_ completion: Subscribers.Completion<Failure>) {
demandBuffer?.complete(completion: completion)
}
func request(_ demand: Subscribers.Demand) {
_ = demandBuffer?.demand(demand)
}
func cancel() {
cancellationHandler?()
cancellationHandler = nil
demandBuffer = nil
}
}
}
#endif

View File

@ -0,0 +1,24 @@
//
// ShareReplay.swift
// CombineExt
//
// Created by Jasdev Singh on 13/04/2020.
// Copyright © 2020 Combine Community. All rights reserved.
//
#if canImport(Combine)
import Combine
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public extension Publisher {
/// A variation on [share()](https://developer.apple.com/documentation/combine/publisher/3204754-share)
/// that allows for buffering and replaying a `replay` amount of value events to future subscribers.
///
/// - Parameter count: The number of value events to buffer in a first-in-first-out manner.
/// - Returns: A publisher that replays the specified number of value events to future subscribers.
func share(replay count: Int) -> Publishers.Autoconnect<Publishers.Multicast<Self, ReplaySubject<Output, Failure>>> {
multicast { ReplaySubject(bufferSize: count) }
.autoconnect()
}
}
#endif

View File

@ -75,6 +75,7 @@ private extension SidebarModel {
.removeDuplicates(by: { previousFeeds, currentFeeds in
return previousFeeds.elementsEqual(currentFeeds, by: { $0.feedID == $1.feedID })
})
.share(replay: 1)
.eraseToAnyPublisher()
}
@ -107,7 +108,7 @@ private extension SidebarModel {
.compactMap { [weak self] _, readFilter, selectedFeeds in
self?.rebuildSidebarItems(isReadFiltered: readFilter, selectedFeeds: selectedFeeds)
}
.share()
.share(replay: 1)
.eraseToAnyPublisher()
}

View File

@ -26,8 +26,8 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
weak var delegate: TimelineModelDelegate?
@Published var nameForDisplay = ""
@Published var selectedArticleIDs = Set<String>() // Don't use directly. Use selectedArticles
@Published var selectedArticleID: String? = nil // Don't use directly. Use selectedArticles
@Published var selectedTimelineItemIDs = Set<String>() // Don't use directly. Use selectedTimelineItemsPublisher
@Published var selectedTimelineItemID: String? = nil // Don't use directly. Use selectedTimelineItemsPublisher
@Published var isReadFiltered: Bool? = nil
var timelineItemsPublisher: AnyPublisher<[TimelineItem], Never>?
@ -40,15 +40,16 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
private var cancellables = Set<AnyCancellable>()
private var sortDirectionSubject = PassthroughSubject<Bool, Never>()
private var groupByFeedSubject = PassthroughSubject<Bool, Never>()
private var sortDirectionSubject = ReplaySubject<Bool, Never>(bufferSize: 1)
private var groupByFeedSubject = ReplaySubject<Bool, Never>(bufferSize: 1)
init(delegate: TimelineModelDelegate) {
self.delegate = delegate
// subscribeToArticleStatusChanges()
subscribeToUserDefaultsChanges()
subscribeToReadFilterChanges()
subscribeToArticleFetchChanges()
subscribeToSelectedArticleSelectionChanges()
// subscribeToArticleStatusChanges()
// subscribeToAccountDidDownloadArticles()
}
@ -78,6 +79,31 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
// }.store(in: &cancellables)
// }
func subscribeToReadFilterChanges() {
guard let selectedFeedsPublisher = delegate?.selectedFeedsPublisher else { return }
selectedFeedsPublisher.sink { [weak self] feeds in
guard let self = self else { return }
guard feeds.count == 1, let timelineFeed = feeds.first else {
self.isReadFiltered = nil
return
}
guard timelineFeed.defaultReadFilterType != .alwaysRead else {
self.isReadFiltered = nil
return
}
if let feedID = timelineFeed.feedID, let readFilterEnabled = self.readFilterEnabledTable[feedID] {
self.isReadFiltered = readFilterEnabled
} else {
self.isReadFiltered = timelineFeed.defaultReadFilterType == .read
}
}
.store(in: &cancellables)
}
func subscribeToUserDefaultsChanges() {
let kickStartNote = Notification(name: Notification.Name("Kick Start"))
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
@ -97,11 +123,12 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
.map { [weak self] feeds -> Set<Article> in
return self?.fetchArticles(feeds: feeds) ?? Set<Article>()
}
.combineLatest($isReadFiltered, sortDirectionPublisher, groupByPublisher)
.compactMap { [weak self] articles, filtered, sortDirection, groupBy -> [TimelineItem] in
.combineLatest(sortDirectionPublisher, groupByPublisher)
.compactMap { [weak self] articles, sortDirection, groupBy -> [TimelineItem] in
let sortedArticles = Array(articles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupBy)
return self?.buildTimelineItems(articles: sortedArticles) ?? [TimelineItem]()
}
.share(replay: 1)
.eraseToAnyPublisher()
}
@ -219,24 +246,6 @@ private extension TimelineModel {
// MARK: Timeline Management
// func resetReadFilter() {
// guard feeds.count == 1, let timelineFeed = feeds.first else {
// isReadFiltered = nil
// return
// }
//
// guard timelineFeed.defaultReadFilterType != .alwaysRead else {
// isReadFiltered = nil
// return
// }
//
// if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] {
// isReadFiltered = readFilterEnabled
// } else {
// isReadFiltered = timelineFeed.defaultReadFilterType == .read
// }
// }
func sortParametersDidChange() {
// performBlockAndRestoreSelection {
// articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)

View File

@ -38,8 +38,8 @@ struct TimelineView: View {
.help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles")
}
ScrollViewReader { scrollViewProxy in
List(timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in
let selected = timelineModel.selectedArticleIDs.contains(timelineItem.article.articleID)
List(timelineItems, selection: $timelineModel.selectedTimelineItemIDs) { timelineItem in
let selected = timelineModel.selectedTimelineItemIDs.contains(timelineItem.article.articleID)
TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem)
.background(TimelineItemFramePreferenceView(timelineItem: timelineItem))
}
@ -48,7 +48,7 @@ struct TimelineView: View {
timelineItemFrames[pref.articleID] = pref.frame
}
}
.onChange(of: timelineModel.selectedArticleIDs) { selectedArticleIDs in
.onChange(of: timelineModel.selectedTimelineItemIDs) { selectedArticleIDs in
let proxyFrame = geometryReaderProxy.frame(in: .global)
for articleID in selectedArticleIDs {
if let itemFrame = timelineItemFrames[articleID] {
@ -70,14 +70,14 @@ struct TimelineView: View {
.navigationTitle(Text(verbatim: timelineModel.nameForDisplay))
#else
ScrollViewReader { scrollViewProxy in
List(timelineModel.timelineItems) { timelineItem in
List(timelineItems) { timelineItem in
ZStack {
let selected = timelineModel.selectedArticleID == timelineItem.article.articleID
let selected = timelineModel.selectedTimelineItemID == timelineItem.article.articleID
TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem)
.background(TimelineItemFramePreferenceView(timelineItem: timelineItem))
NavigationLink(destination: ArticleContainerView(),
tag: timelineItem.article.articleID,
selection: $timelineModel.selectedArticleID) {
selection: $timelineModel.selectedTimelineItemID) {
EmptyView()
}.buttonStyle(PlainButtonStyle())
}
@ -87,7 +87,7 @@ struct TimelineView: View {
timelineItemFrames[pref.articleID] = pref.frame
}
}
.onChange(of: timelineModel.selectedArticleID) { selectedArticleID in
.onChange(of: timelineModel.selectedTimelineItemID) { selectedArticleID in
let proxyFrame = geometryReaderProxy.frame(in: .global)
if let articleID = selectedArticleID, let itemFrame = timelineItemFrames[articleID] {
if itemFrame.minY < proxyFrame.minY + 3 || itemFrame.maxY > proxyFrame.maxY - 3 {
@ -98,6 +98,12 @@ struct TimelineView: View {
}
}
}
.onReceive(timelineModel.timelineItemsPublisher!) { items in
// Animations crash on iPadOS right now
// withAnimation {
timelineItems = items
// }
}
.navigationBarTitle(Text(verbatim: timelineModel.nameForDisplay), displayMode: .inline)
#endif
}

View File

@ -61,9 +61,9 @@ class ArticleViewController: UIViewController {
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
])
selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in
self?.articles = articles
}
// selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in
// self?.articles = articles
// }
let controller = createWebViewController(currentArticle, updateView: true)
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)

View File

@ -340,6 +340,10 @@
51A8001324CA0FC700F41F1D /* Sink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001124CA0FC700F41F1D /* Sink.swift */; };
51A8001524CA0FEC00F41F1D /* DemandBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */; };
51A8001624CA0FEC00F41F1D /* DemandBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */; };
51A8002D24CC451500F41F1D /* ShareReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8002C24CC451500F41F1D /* ShareReplay.swift */; };
51A8002E24CC451600F41F1D /* ShareReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8002C24CC451500F41F1D /* ShareReplay.swift */; };
51A8005124CC453C00F41F1D /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8005024CC453C00F41F1D /* ReplaySubject.swift */; };
51A8005224CC453C00F41F1D /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8005024CC453C00F41F1D /* ReplaySubject.swift */; };
51A8FFED24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */; };
51A8FFEE24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */; };
51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; };
@ -2036,6 +2040,8 @@
51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedDefaultContainer.swift; sourceTree = "<group>"; };
51A8001124CA0FC700F41F1D /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = "<group>"; };
51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemandBuffer.swift; sourceTree = "<group>"; };
51A8002C24CC451500F41F1D /* ShareReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareReplay.swift; sourceTree = "<group>"; };
51A8005024CC453C00F41F1D /* ReplaySubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = "<group>"; };
51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WIthLatestFrom.swift; sourceTree = "<group>"; };
51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerAccountCell.xib; sourceTree = "<group>"; };
51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerFolderCell.xib; sourceTree = "<group>"; };
@ -3044,6 +3050,8 @@
51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */,
51A8001124CA0FC700F41F1D /* Sink.swift */,
51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */,
51A8002C24CC451500F41F1D /* ShareReplay.swift */,
51A8005024CC453C00F41F1D /* ReplaySubject.swift */,
);
path = CombineExt;
sourceTree = "<group>";
@ -4331,46 +4339,46 @@
TargetAttributes = {
51314636235A7BBE00387FDC = {
CreatedOnToolsVersion = 11.2;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
LastSwiftMigration = 1120;
ProvisioningStyle = Automatic;
};
513C5CE5232571C2003D4054 = {
CreatedOnToolsVersion = 11.0;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
518B2ED12351B3DD00400001 = {
CreatedOnToolsVersion = 11.2;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
TestTargetID = 840D617B2029031C009BC708;
};
51C0513C24A77DF800194D5E = {
CreatedOnToolsVersion = 12.0;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
51C0514324A77DF800194D5E = {
CreatedOnToolsVersion = 12.0;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
6581C73220CED60000F4AD34 = {
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
65ED3FA2235DEF6C0081F399 = {
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
65ED4090235DEF770081F399 = {
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
840D617B2029031C009BC708 = {
CreatedOnToolsVersion = 9.3;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.BackgroundModes = {
@ -4380,7 +4388,7 @@
};
849C645F1ED37A5D003D8FC0 = {
CreatedOnToolsVersion = 8.2.1;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.HardenedRuntime = {
@ -4390,7 +4398,7 @@
};
849C64701ED37A5D003D8FC0 = {
CreatedOnToolsVersion = 8.2.1;
DevelopmentTeam = FQLBNX3GP7;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
TestTargetID = 849C645F1ED37A5D003D8FC0;
};
@ -5241,6 +5249,7 @@
65082A5224C72B88009FA994 /* SettingsCredentialsAccountModel.swift in Sources */,
172199C924AB228900A31D04 /* SettingsView.swift in Sources */,
51B8BCC224C25C3E00360B00 /* SidebarContextMenu.swift in Sources */,
51A8005124CC453C00F41F1D /* ReplaySubject.swift in Sources */,
17D232A824AFF10A0005F075 /* AddWebFeedModel.swift in Sources */,
51B80EB824BD1F8B00C6C32D /* ActivityViewController.swift in Sources */,
51E4994224A8713C00B667CB /* ArticleStatusSyncTimer.swift in Sources */,
@ -5308,6 +5317,7 @@
51919FEE24AB85E400541E64 /* TimelineContainerView.swift in Sources */,
653A4E7924BCA5BB00EF2D7F /* SettingsCloudKitAccountView.swift in Sources */,
51E4995724A8734D00B667CB /* ExtensionPoint.swift in Sources */,
51A8002D24CC451500F41F1D /* ShareReplay.swift in Sources */,
51B8BCE624C25F7C00360B00 /* TimelineContextMenu.swift in Sources */,
1776E88E24AC5F8A00E78166 /* AppDefaults.swift in Sources */,
51E4991124A808DE00B667CB /* SmallIconProvider.swift in Sources */,
@ -5361,6 +5371,7 @@
51E498CB24A8085D00B667CB /* TodayFeedDelegate.swift in Sources */,
51B80F1F24BE531200C6C32D /* SharingServiceView.swift in Sources */,
17D232A924AFF10A0005F075 /* AddWebFeedModel.swift in Sources */,
51A8005224CC453C00F41F1D /* ReplaySubject.swift in Sources */,
51E4993324A867E700B667CB /* AppNotifications.swift in Sources */,
51B80F4224BE588200C6C32D /* SharingServicePickerDelegate.swift in Sources */,
51E4990624A808C300B667CB /* ImageDownloader.swift in Sources */,
@ -5418,6 +5429,7 @@
514E6BDB24ACEA0400AC6F6E /* TimelineItemView.swift in Sources */,
51B8BCE724C25F7C00360B00 /* TimelineContextMenu.swift in Sources */,
51E4996E24A8764C00B667CB /* ActivityManager.swift in Sources */,
51A8002E24CC451600F41F1D /* ShareReplay.swift in Sources */,
1769E33024BD6271000E1E8E /* EditAccountCredentialsView.swift in Sources */,
51E4995A24A873F900B667CB /* ErrorHandler.swift in Sources */,
5194737124BBCAF4001A2939 /* TimelineSortOrderView.swift in Sources */,