Refactoring

This commit is contained in:
Justin Mazzocchi 2020-09-22 18:00:56 -07:00
parent 55ba5f856a
commit b13f4b89a8
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
20 changed files with 410 additions and 111 deletions

View file

@ -0,0 +1,14 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ViewModels
extension CollectionItem.Kind {
var cellClass: AnyClass {
switch self {
case .status:
return StatusListCell.self
case .account:
return AccountListCell.self
}
}
}

View file

@ -30,14 +30,13 @@
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; };
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* StatusListView.swift */; };
D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* CollectionView.swift */; };
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42624F76169001EBDBB /* PreferencesView.swift */; };
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42724F76169001EBDBB /* RootView.swift */; };
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */; };
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */; };
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */; };
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */; };
D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D43124F76169001EBDBB /* StatusListViewController.swift */; };
D0C7D4C224F7616A001EBDBB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45224F76169001EBDBB /* Assets.xcassets */; };
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45424F76169001EBDBB /* MetatextApp.swift */; };
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45524F76169001EBDBB /* AppDelegate.swift */; };
@ -51,6 +50,11 @@
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; };
D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; };
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */; };
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; };
D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* CollectionViewController.swift */; };
D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -116,14 +120,13 @@
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; };
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomEmojiText.swift; sourceTree = "<group>"; };
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddIdentityView.swift; sourceTree = "<group>"; };
D0C7D42524F76169001EBDBB /* StatusListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListView.swift; sourceTree = "<group>"; };
D0C7D42524F76169001EBDBB /* CollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = "<group>"; };
D0C7D42624F76169001EBDBB /* PreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
D0C7D42724F76169001EBDBB /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = "<group>"; };
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationView.swift; sourceTree = "<group>"; };
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = "<group>"; };
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigationView.swift; sourceTree = "<group>"; };
D0C7D43124F76169001EBDBB /* StatusListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListViewController.swift; sourceTree = "<group>"; };
D0C7D45224F76169001EBDBB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D0C7D45424F76169001EBDBB /* MetatextApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = "<group>"; };
D0C7D45524F76169001EBDBB /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -141,6 +144,11 @@
D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = "<group>"; };
D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = "<group>"; };
D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = "<group>"; };
D0F0B12D251A97E400942152 /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItemKind+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -257,9 +265,13 @@
D0C7D42024F76169001EBDBB /* Views */ = {
isa = PBXGroup;
children = (
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */,
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */,
D0F0B125251A90F400942152 /* AccountListCell.swift */,
D0F0B10D251A868200942152 /* AccountView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D01F41E024F8885900D55A2D /* Attachments */,
D0C7D42524F76169001EBDBB /* CollectionView.swift */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
@ -274,7 +286,6 @@
D02E1F94250B13210071AD56 /* SafariView.swift */,
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
D0625E55250F086B00502611 /* Status */,
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
);
@ -284,7 +295,7 @@
D0C7D43024F76169001EBDBB /* View Controllers */ = {
isa = PBXGroup;
children = (
D0C7D43124F76169001EBDBB /* StatusListViewController.swift */,
D0F0B12D251A97E400942152 /* CollectionViewController.swift */,
);
path = "View Controllers";
sourceTree = "<group>";
@ -311,6 +322,7 @@
isa = PBXGroup;
children = (
D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */,
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */,
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
@ -502,12 +514,15 @@
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */,
D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */,
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */,
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
@ -519,6 +534,7 @@
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,

View file

@ -0,0 +1,26 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct AccountListService {
public let accountSections: AnyPublisher<[[Account]], Error>
public let paginates: Bool
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
private let requestClosure: (_ maxID: String?, _ minID: String?) -> AnyPublisher<Never, Error>
}
extension AccountListService {
}
public extension AccountListService {
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
requestClosure(maxID, minID)
}
}

View file

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct AccountService {
public let account: Account
public let urlService: URLService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(account: Account, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.account = account
self.urlService = URLService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase
}
}

View file

@ -5,30 +5,36 @@ import SafariServices
import SwiftUI
import ViewModels
class StatusListViewController: UITableViewController {
private let viewModel: StatusListViewModel
class CollectionViewController: UITableViewController {
private let viewModel: CollectionViewModel
private let loadingTableFooterView = LoadingTableFooterView()
private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [String: CGFloat]]()
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
private let dataSourceQueue =
DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue")
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
private lazy var dataSource: UITableViewDiffableDataSource<Int, String> = {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in
guard
let self = self,
let cell = tableView.dequeueReusableCell(
withIdentifier: String(describing: StatusListCell.self),
for: indexPath) as? StatusListCell
else { return nil }
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItem> = {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil }
cell.viewModel = self.viewModel.statusViewModel(id: statusID)
let cell = tableView.dequeueReusableCell(
withIdentifier: String(describing: item.kind.cellClass),
for: indexPath)
switch (cell, cellViewModel) {
case (let statusListCell as StatusListCell, let statusViewModel as StatusViewModel):
statusListCell.viewModel = statusViewModel
case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel):
accountListCell.viewModel = accountViewModel
default:
return nil
}
return cell
}
}()
init(viewModel: StatusListViewModel) {
init(viewModel: CollectionViewModel) {
self.viewModel = viewModel
super.init(style: .plain)
@ -42,33 +48,35 @@ class StatusListViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(StatusListCell.self, forCellReuseIdentifier: String(describing: StatusListCell.self))
for kind in CollectionItem.Kind.allCases {
tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass))
}
tableView.dataSource = dataSource
tableView.prefetchDataSource = self
tableView.cellLayoutMarginsFollowReadableWidth = true
tableView.tableFooterView = UIView()
navigationItem.title = viewModel.title
// navigationItem.title = viewModel.title
viewModel.$statusIDs
.sink { [weak self] in self?.update(statusIDs: $0) }
viewModel.collectionItems
.sink { [weak self] in self?.update(items: $0) }
.store(in: &cancellables)
viewModel.events.sink { [weak self] in
viewModel.navigationEvents.sink { [weak self] in
guard let self = self else { return }
switch $0 {
case let .share(url):
self.share(url: url)
case let .statusListNavigation(statusListViewModel):
self.show(StatusListViewController(viewModel: statusListViewModel), sender: self)
case let .collectionNavigation(collectionViewModel):
self.show(CollectionViewController(viewModel: collectionViewModel), sender: self)
case let .urlNavigation(url):
self.present(SFSafariViewController(url: url), animated: true)
}
}
.store(in: &cancellables)
viewModel.$loading
viewModel.loading
.receive(on: RunLoop.main)
.sink { [weak self] in
guard let self = self else { return }
@ -97,7 +105,7 @@ class StatusListViewController: UITableViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.request()
viewModel.request(maxID: nil, minID: nil)
}
override func tableView(_ tableView: UITableView,
@ -105,7 +113,7 @@ class StatusListViewController: UITableViewController {
forRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
var heightCache = cellHeightCaches[tableView.frame.width] ?? [String: CGFloat]()
var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]()
heightCache[item] = cell.frame.height
cellHeightCaches[tableView.frame.width] = heightCache
@ -118,15 +126,15 @@ class StatusListViewController: UITableViewController {
}
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
guard let id = dataSource.itemIdentifier(for: indexPath) else { return true }
guard let item = dataSource.itemIdentifier(for: indexPath) else { return true }
return id != viewModel.contextParentID
return viewModel.canSelect(item: item)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
show(StatusListViewController(viewModel: viewModel.contextViewModel(id: id)), sender: self)
viewModel.itemSelected(item)
}
override func viewDidLayoutSubviews() {
@ -136,27 +144,27 @@ class StatusListViewController: UITableViewController {
}
}
extension StatusListViewController: UITableViewDataSourcePrefetching {
extension CollectionViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
guard
viewModel.paginates,
let indexPath = indexPaths.last,
indexPath.section == dataSource.numberOfSections(in: tableView) - 1,
indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1,
let maxID = dataSource.itemIdentifier(for: indexPath)
let maxID = dataSource.itemIdentifier(for: indexPath)?.id
else { return }
viewModel.request(maxID: maxID)
viewModel.request(maxID: maxID, minID: nil)
}
}
private extension StatusListViewController {
func update(statusIDs: [[String]]) {
private extension CollectionViewController {
func update(items: [[CollectionItem]]) {
var offsetFromNavigationBar: CGFloat?
if
let id = viewModel.maintainScrollPositionOfStatusID,
let indexPath = dataSource.indexPath(for: id),
let item = viewModel.maintainScrollPositionOfItem,
let indexPath = dataSource.indexPath(for: item),
let navigationBar = navigationController?.navigationBar {
let navigationBarMaxY = tableView.convert(navigationBar.bounds, from: navigationBar).maxY
offsetFromNavigationBar = tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY
@ -165,10 +173,10 @@ private extension StatusListViewController {
dataSourceQueue.async { [weak self] in
guard let self = self else { return }
self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) {
self.dataSource.apply(items.snapshot(), animatingDifferences: false) {
if
let id = self.viewModel.maintainScrollPositionOfStatusID,
let indexPath = self.dataSource.indexPath(for: id) {
let item = self.viewModel.maintainScrollPositionOfItem,
let indexPath = self.dataSource.indexPath(for: item) {
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
if let offsetFromNavigationBar = offsetFromNavigationBar {

View file

@ -0,0 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public class AccountListViewModel: ObservableObject {
}

View file

@ -39,7 +39,7 @@ public class AccountStatusesViewModel: StatusListViewModel {
}
override func isPinned(status: Status) -> Bool {
collection == .statuses && statusIDs.first?.contains(status.id) ?? false
collection == .statuses && items.first?.contains(CollectionItem(id: status.id, kind: .status)) ?? false
}
}

View file

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import ServiceLayer
public class AccountViewModel: ObservableObject {
private let accountService: AccountService
init(accountService: AccountService) {
self.accountService = accountService
}
}
public extension AccountViewModel {
var avatarURL: URL {
accountService.account.avatar
}
var note: NSAttributedString {
accountService.account.note.attributed
}
}

View file

@ -0,0 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
public protocol CollectionViewModel {
var collectionItems: AnyPublisher<[[CollectionItem]], Never> { get }
var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get }
var navigationEvents: AnyPublisher<NavigationEvent, Never> { get }
var paginates: Bool { get }
var maintainScrollPositionOfItem: CollectionItem? { get }
func request(maxID: String?, minID: String?)
func itemSelected(_ item: CollectionItem)
func canSelect(item: CollectionItem) -> Bool
func viewModel(item: CollectionItem) -> Any?
}

View file

@ -0,0 +1,13 @@
// Copyright © 2020 Metabolist. All rights reserved.
public struct CollectionItem: Hashable {
public let id: String
public let kind: Kind
}
public extension CollectionItem {
enum Kind: Hashable, CaseIterable {
case status
case account
}
}

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public enum NavigationEvent {
case collectionNavigation(CollectionViewModel)
case urlNavigation(URL)
case share(URL)
}

View file

@ -6,21 +6,22 @@ import Mastodon
import ServiceLayer
public class StatusListViewModel: ObservableObject {
@Published public private(set) var statusIDs = [[String]]()
@Published public private(set) var items = [[CollectionItem]]()
@Published public var alertItem: AlertItem?
@Published public private(set) var loading = false
public let events: AnyPublisher<Event, Never>
public private(set) var maintainScrollPositionOfStatusID: String?
public let navigationEvents: AnyPublisher<NavigationEvent, Never>
public private(set) var maintainScrollPositionOfItem: CollectionItem?
private var statuses = [String: Status]()
private var flatStatusIDs = [String]()
private let statusListService: StatusListService
private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]()
private let eventsSubject = PassthroughSubject<Event, Never>()
private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>()
init(statusListService: StatusListService) {
self.statusListService = statusListService
events = eventsSubject.eraseToAnyPublisher()
navigationEvents = navigationEventsSubject.eraseToAnyPublisher()
statusListService.statusSections
.combineLatest(statusListService.filters.map { $0.regularExpression() })
@ -29,11 +30,12 @@ public class StatusListViewModel: ObservableObject {
self?.determineIfScrollPositionShouldBeMaintained(newStatusSections: $0)
self?.cleanViewModelCache(newStatusSections: $0)
self?.statuses = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
self?.flatStatusIDs = $0.reduce([], +).map(\.id)
})
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.map { $0.map { $0.map(\.id) } }
.assign(to: &$statusIDs)
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } }
.assign(to: &$items)
}
public func request(maxID: String? = nil, minID: String? = nil) {
@ -41,8 +43,8 @@ public class StatusListViewModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.loading = true },
receiveCompletion: { [weak self] _ in self?.loading = false })
receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) },
receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) })
.sink { _ in }
.store(in: &cancellables)
}
@ -50,11 +52,44 @@ public class StatusListViewModel: ObservableObject {
func isPinned(status: Status) -> Bool { false }
}
public extension StatusListViewModel {
enum Event {
case statusListNavigation(StatusListViewModel)
case urlNavigation(URL)
case share(URL)
extension StatusListViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() }
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> {
loadingSubject.eraseToAnyPublisher()
}
public func itemSelected(_ item: CollectionItem) {
switch item.kind {
case .status:
let displayStatusID = statuses[item.id]?.displayStatus.id ?? item.id
navigationEventsSubject.send(
.collectionNavigation(
StatusListViewModel(
statusListService: statusListService.contextService(statusID: displayStatusID))))
default:
break
}
}
public func canSelect(item: CollectionItem) -> Bool {
if case .status = item.kind, item.id == statusListService.contextParentID {
return false
}
return true
}
public func viewModel(item: CollectionItem) -> Any? {
switch item.kind {
case .status:
return statusViewModel(id: item.id)
default:
return nil
}
}
}
@ -80,9 +115,9 @@ public extension StatusListViewModel {
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in
guard let self = self,
let event = self.event(statusEvent: $0)
let event = self.navigationEvent(statusEvent: $0)
else { return }
self.eventsSubject.send(event)
self.navigationEventsSubject.send(event)
})
}
@ -93,12 +128,6 @@ public extension StatusListViewModel {
return statusViewModel
}
func contextViewModel(id: String) -> StatusListViewModel {
let displayStatusID = statuses[id]?.displayStatus.id ?? id
return StatusListViewModel(statusListService: statusListService.contextService(statusID: displayStatusID))
}
}
private extension StatusListViewModel {
@ -110,7 +139,7 @@ private extension StatusListViewModel {
}
}
func event(statusEvent: StatusViewModel.Event) -> Event? {
func navigationEvent(statusEvent: StatusViewModel.Event) -> NavigationEvent? {
switch statusEvent {
case .ignorableOutput:
return nil
@ -119,30 +148,31 @@ private extension StatusListViewModel {
case let .url(url):
return .urlNavigation(url)
case let .accountID(id):
return .statusListNavigation(
return .collectionNavigation(
AccountStatusesViewModel(accountStatusesService: statusListService.service(accountID: id)))
case let .statusID(id):
return .statusListNavigation(
return .collectionNavigation(
StatusListViewModel(
statusListService: statusListService.contextService(statusID: id)))
case let .tag(tag):
return .statusListNavigation(
return .collectionNavigation(
StatusListViewModel(
statusListService: statusListService.service(timeline: Timeline.tag(tag))))
}
case let .accountListNavigation(accountListViewModel):
// return .collectionNavigation(accountListViewModel)
return nil
case let .share(url):
return .share(url)
}
}
func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) {
maintainScrollPositionOfStatusID = nil // clear old value
let flatStatusIDs = statusIDs.reduce([], +)
maintainScrollPositionOfItem = nil // clear old value
// Maintain scroll position of parent after initial load of context
if let contextParentID = contextParentID, flatStatusIDs == [contextParentID] || flatStatusIDs == [] {
maintainScrollPositionOfStatusID = contextParentID
maintainScrollPositionOfItem = CollectionItem(id: contextParentID, kind: .status)
}
}
@ -153,8 +183,6 @@ private extension StatusListViewModel {
}
func isReplyInContext(status: Status) -> Bool {
let flatStatusIDs = statusIDs.reduce([], +)
guard
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
index > 0
@ -166,8 +194,6 @@ private extension StatusListViewModel {
}
func hasReplyFollowing(status: Status) -> Bool {
let flatStatusIDs = statusIDs.reduce([], +)
guard
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
flatStatusIDs.count > index + 1,

View file

@ -53,6 +53,7 @@ public extension StatusViewModel {
enum Event {
case ignorableOutput
case navigation(URLItem)
case accountListNavigation(AccountListViewModel)
case share(URL)
}
}
@ -130,6 +131,10 @@ public extension StatusViewModel {
.eraseToAnyPublisher())
}
func favoritedBySelected() {
}
func toggleFavorited() {
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct AccountContentConfiguration {
let viewModel: AccountViewModel
}
extension AccountContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
AccountView(configuration: self)
}
func updated(for state: UIConfigurationState) -> AccountContentConfiguration {
self
}
}

View file

@ -0,0 +1,23 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
class AccountListCell: UITableViewCell {
var viewModel: AccountViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = AccountContentConfiguration(viewModel: viewModel).updated(for: state)
}
override func layoutSubviews() {
super.layoutSubviews()
let isPhoneIdiom = UIDevice.current.userInterfaceIdiom == .phone
separatorInset.right = isPhoneIdiom ? 0 : layoutMargins.right
separatorInset.left = isPhoneIdiom ? 0 : layoutMargins.left
}
}

64
Views/AccountView.swift Normal file
View file

@ -0,0 +1,64 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Kingfisher
import UIKit
class AccountView: UIView {
let avatarImageView = AnimatedImageView()
let noteTextView = TouchFallthroughTextView()
private var accountConfiguration: AccountContentConfiguration
init(configuration: AccountContentConfiguration) {
self.accountConfiguration = configuration
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension AccountView: UIContentView {
var configuration: UIContentConfiguration {
get { accountConfiguration }
set {
guard let accountConfiguration = newValue as? AccountContentConfiguration else { return }
self.accountConfiguration = accountConfiguration
avatarImageView.kf.cancelDownloadTask()
applyAccountConfiguration()
}
}
}
private extension AccountView {
func initialSetup() {
let baseStackView = UIStackView()
addSubview(baseStackView)
baseStackView.translatesAutoresizingMaskIntoConstraints = false
baseStackView.addArrangedSubview(avatarImageView)
baseStackView.addArrangedSubview(noteTextView)
noteTextView.isScrollEnabled = false
NSLayoutConstraint.activate([
baseStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
baseStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
])
applyAccountConfiguration()
}
func applyAccountConfiguration() {
avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL)
noteTextView.attributedText = accountConfiguration.viewModel.note
}
}

View file

@ -0,0 +1,26 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
import ViewModels
struct CollectionView: UIViewControllerRepresentable {
let viewModel: CollectionViewModel
func makeUIViewController(context: Context) -> CollectionViewController {
CollectionViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: CollectionViewController, context: Context) {
}
}
#if DEBUG
import PreviewViewModels
struct StatusListView_Previews: PreviewProvider {
static var previews: some View {
CollectionView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home))
}
}
#endif

View file

@ -148,15 +148,20 @@ private extension StatusView {
let accountAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() }
avatarButton.addAction(accountAction, for: .touchUpInside)
contextParentAvatarButton.addAction(accountAction, for: .touchUpInside)
let favoriteAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleFavorited() }
favoriteButton.addAction(favoriteAction, for: .touchUpInside)
contextParentFavoriteButton.addAction(favoriteAction, for: .touchUpInside)
let shareAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() }
shareButton.addAction(
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() },
for: .touchUpInside)
shareButton.addAction(shareAction, for: .touchUpInside)
contextParentFavoritedByButton.addAction(
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.favoritedBySelected() },
for: .touchUpInside)
applyStatusConfiguration()
}

View file

@ -1,26 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
import ViewModels
struct StatusListView: UIViewControllerRepresentable {
let viewModel: StatusListViewModel
func makeUIViewController(context: Context) -> StatusListViewController {
StatusListViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: StatusListViewController, context: Context) {
}
}
#if DEBUG
import PreviewViewModels
struct StatusListView_Previews: PreviewProvider {
static var previews: some View {
StatusListView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home))
}
}
#endif

View file

@ -61,7 +61,7 @@ private extension TabNavigationView {
func view(tab: NavigationViewModel.Tab) -> some View {
switch tab {
case .timelines:
StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
CollectionView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
.id(viewModel.timeline.id)
.edgesIgnoringSafeArea(.all)
.navigationTitle(viewModel.timeline.title)