Re-write identity management in UIKit

This commit is contained in:
Justin Mazzocchi 2021-01-28 15:15:22 -08:00
parent 1151fc7563
commit 1c295a1f5a
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
11 changed files with 378 additions and 88 deletions

View File

@ -0,0 +1,127 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
enum IdentitiesSection: Hashable {
case add
case identities(String)
}
enum IdentitiesItem: Hashable {
case add
case identitiy(Identity)
}
final class IdentitiesDataSource: UITableViewDiffableDataSource<IdentitiesSection, IdentitiesItem> {
private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.identities-data-source.update-queue")
private var cancellables = Set<AnyCancellable>()
private let deleteAction: (Identity) -> Void
init(tableView: UITableView,
publisher: AnyPublisher<[Identity], Never>,
viewModelProvider: @escaping (Identity) -> IdentityViewModel,
deleteAction: @escaping (Identity) -> Void) {
self.deleteAction = deleteAction
tableView.register(UITableViewCell.self,
forCellReuseIdentifier: String(describing: UITableViewCell.self))
tableView.register(IdentityTableViewCell.self,
forCellReuseIdentifier: String(describing: IdentityTableViewCell.self))
super.init(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: item.cellReuseIdentifier,
for: indexPath)
switch item {
case .add:
var configuration = cell.defaultContentConfiguration()
configuration.text = NSLocalizedString("add", comment: "")
configuration.image = UIImage(systemName: "plus.circle.fill")
cell.contentConfiguration = configuration
case let .identitiy(identity):
let viewModel = viewModelProvider(identity)
(cell as? IdentityTableViewCell)?.viewModel = viewModel
cell.accessoryType = identity.id == viewModel.identityContext.identity.id ? .checkmark : .none
}
return cell
}
publisher
.sink { [weak self] in self?.update(identities: $0) }
.store(in: &cancellables)
}
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<IdentitiesSection, IdentitiesItem>,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async {
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
let currentSnapshot = snapshot()
let section = currentSnapshot.sectionIdentifiers[section]
if currentSnapshot.numberOfItems(inSection: section) > 0, case let .identities(title) = section {
return title
}
return nil
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
itemIdentifier(for: indexPath) != .add
}
override func tableView(_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath) {
guard editingStyle == .delete,
case let .identitiy(identity) = itemIdentifier(for: indexPath)
else { return }
deleteAction(identity)
}
}
private extension IdentitiesDataSource {
private func update(identities: [Identity]) {
var newSnapshot = NSDiffableDataSourceSnapshot<IdentitiesSection, IdentitiesItem>()
let sections = [
(section: IdentitiesSection.identities(NSLocalizedString("identities.accounts", comment: "")),
identities: identities.filter { $0.authenticated && !$0.pending }.map(IdentitiesItem.identitiy)),
(section: IdentitiesSection.identities(NSLocalizedString("identities.browsing", comment: "")),
identities: identities.filter { !$0.authenticated && !$0.pending }.map(IdentitiesItem.identitiy)),
(section: IdentitiesSection.identities(NSLocalizedString("identities.pending", comment: "")),
identities: identities.filter { $0.pending }.map(IdentitiesItem.identitiy))
]
.filter { !$0.identities.isEmpty }
newSnapshot.appendSections([.add] + sections.map(\.section))
newSnapshot.appendItems([.add], toSection: .add)
for section in sections {
newSnapshot.appendItems(section.identities, toSection: section.section)
}
apply(newSnapshot, animatingDifferences: !snapshot().sectionIdentifiers.filter { $0 != .add }.isEmpty)
}
}
private extension IdentitiesItem {
var cellReuseIdentifier: String {
switch self {
case .add:
return String(describing: UITableViewCell.self)
case .identitiy:
return String(describing: IdentityTableViewCell.self)
}
}
}

View File

@ -21,6 +21,11 @@
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; }; D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; };
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; }; D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
D021A5F625C34538008A0C0D /* IdentitiesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021A5F525C34538008A0C0D /* IdentitiesViewController.swift */; };
D021A60025C3478F008A0C0D /* IdentitiesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */; };
D021A60A25C36B32008A0C0D /* IdentityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */; };
D021A61425C36BFB008A0C0D /* IdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021A61325C36BFB008A0C0D /* IdentityView.swift */; };
D021A61A25C36C1A008A0C0D /* IdentityContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */; };
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
D035F86925B7F2ED00DC75ED /* MainNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */; }; D035F86925B7F2ED00DC75ED /* MainNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */; };
D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */; }; D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */; };
@ -213,6 +218,11 @@
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = "<group>"; }; D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = "<group>"; };
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; }; D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; }; D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; };
D021A5F525C34538008A0C0D /* IdentitiesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesViewController.swift; sourceTree = "<group>"; };
D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesDataSource.swift; sourceTree = "<group>"; };
D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityTableViewCell.swift; sourceTree = "<group>"; };
D021A61325C36BFB008A0C0D /* IdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityView.swift; sourceTree = "<group>"; };
D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityContentConfiguration.swift; sourceTree = "<group>"; };
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewController.swift; sourceTree = "<group>"; }; D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewController.swift; sourceTree = "<group>"; };
D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationView.swift; sourceTree = "<group>"; }; D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationView.swift; sourceTree = "<group>"; };
@ -478,6 +488,7 @@
D0A1F4F5252E7D2A004435BF /* Data Sources */ = { D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */,
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
); );
path = "Data Sources"; path = "Data Sources";
@ -522,6 +533,9 @@
D07EC7F125B13E57006DF726 /* EmojiView.swift */, D07EC7F125B13E57006DF726 /* EmojiView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */,
D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */,
D021A61325C36BFB008A0C0D /* IdentityView.swift */,
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */,
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
@ -570,6 +584,7 @@
D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */,
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */, D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */,
D087671525BAA8C0001FDD43 /* ExploreViewController.swift */, D087671525BAA8C0001FDD43 /* ExploreViewController.swift */,
D021A5F525C34538008A0C0D /* IdentitiesViewController.swift */,
D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */,
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */,
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */,
@ -851,6 +866,7 @@
D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */, D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */,
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */, D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
D021A5F625C34538008A0C0D /* IdentitiesViewController.swift in Sources */,
D07EC7DC25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */, D07EC7DC25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
@ -881,6 +897,7 @@
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
D021A60A25C36B32008A0C0D /* IdentityTableViewCell.swift in Sources */,
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */, D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */,
D0F4362D25C10B9600E4F896 /* AddIdentityViewController.swift in Sources */, D0F4362D25C10B9600E4F896 /* AddIdentityViewController.swift in Sources */,
D0625E59250F092900502611 /* StatusListCell.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
@ -889,6 +906,7 @@
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D07EC7E325B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */, D07EC7E325B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */,
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */,
D021A61A25C36C1A008A0C0D /* IdentityContentConfiguration.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
@ -905,6 +923,7 @@
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */, D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D021A61425C36BFB008A0C0D /* IdentityView.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */, D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */,
D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */, D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */,
@ -914,6 +933,7 @@
D0EA59402522AC8700804347 /* CardView.swift in Sources */, D0EA59402522AC8700804347 /* CardView.swift in Sources */,
D0F0B10E251A868200942152 /* AccountView.swift in Sources */, D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */, D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D021A60025C3478F008A0C0D /* IdentitiesDataSource.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */, D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */, D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,

View File

@ -0,0 +1,63 @@
// Copyright © 2021 Metabolist. All rights reserved.
import SwiftUI
import ViewModels
final class IdentitiesViewController: UITableViewController {
private let viewModel: IdentitiesViewModel
private let rootViewModel: RootViewModel
private lazy var dataSource: IdentitiesDataSource = {
.init(tableView: tableView,
publisher: viewModel.$identities.eraseToAnyPublisher(),
viewModelProvider: viewModel.viewModel(identity:),
deleteAction: { [weak self] in self?.rootViewModel.deleteIdentity(id: $0.id) })
}()
init(viewModel: IdentitiesViewModel, rootViewModel: RootViewModel) {
self.viewModel = viewModel
self.rootViewModel = rootViewModel
super.init(style: .insetGrouped)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = dataSource
}
override func didMove(toParent parent: UIViewController?) {
parent?.navigationItem.title = NSLocalizedString("secondary-navigation.accounts", comment: "")
parent?.navigationItem.rightBarButtonItem = editButtonItem
}
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
if case let .identitiy(identityViewModel) = dataSource.itemIdentifier(for: indexPath) {
return identityViewModel.id != viewModel.identityContext.identity.id
}
return true
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .add:
let addIdentityViewModel = rootViewModel.addIdentityViewModel()
let addIdentityView = AddIdentityView(viewModelClosure: { addIdentityViewModel }, displayWelcome: false)
.environmentObject(rootViewModel)
let addIdentityViewController = UIHostingController(rootView: addIdentityView)
show(addIdentityViewController, sender: self)
case let .identitiy(identityViewModel):
rootViewModel.identitySelected(id: identityViewModel.id)
}
}
}

View File

@ -5,7 +5,6 @@ import Foundation
import ServiceLayer import ServiceLayer
public final class IdentitiesViewModel: ObservableObject { public final class IdentitiesViewModel: ObservableObject {
public let currentIdentityId: Identity.Id
@Published public private(set) var identities = [Identity]() @Published public private(set) var identities = [Identity]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public let identityContext: IdentityContext public let identityContext: IdentityContext
@ -14,10 +13,16 @@ public final class IdentitiesViewModel: ObservableObject {
public init(identityContext: IdentityContext) { public init(identityContext: IdentityContext) {
self.identityContext = identityContext self.identityContext = identityContext
currentIdentityId = identityContext.identity.id
identityContext.service.identitiesPublisher() identityContext.service.identitiesPublisher()
.receive(on: RunLoop.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$identities) .assign(to: &$identities)
} }
} }
public extension IdentitiesViewModel {
func viewModel(identity: Identity) -> IdentityViewModel {
.init(identity: identity, identityContext: identityContext)
}
}

View File

@ -0,0 +1,13 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public final class IdentityViewModel: ObservableObject {
public let identity: Identity
public let identityContext: IdentityContext
init(identity: Identity, identityContext: IdentityContext) {
self.identity = identity
self.identityContext = identityContext
}
}

View File

@ -4,91 +4,15 @@ import Kingfisher
import SwiftUI import SwiftUI
import ViewModels import ViewModels
struct IdentitiesView: View { struct IdentitiesView: UIViewControllerRepresentable {
@StateObject var viewModel: IdentitiesViewModel let viewModelClosure: () -> IdentitiesViewModel
@EnvironmentObject var rootViewModel: RootViewModel @EnvironmentObject var rootViewModel: RootViewModel
@Environment(\.displayScale) var displayScale: CGFloat
var body: some View { func makeUIViewController(context: Context) -> IdentitiesViewController {
Form { IdentitiesViewController(viewModel: viewModelClosure(), rootViewModel: rootViewModel)
Section { }
NavigationLink(
destination: AddIdentityView( func updateUIViewController(_ uiViewController: IdentitiesViewController, context: Context) {
viewModelClosure: { rootViewModel.addIdentityViewModel() },
displayWelcome: false),
label: {
Label("add", systemImage: "plus.circle")
})
}
section(title: "identities.accounts",
identities: viewModel.identities.filter { $0.authenticated && !$0.pending })
section(title: "identities.browsing",
identities: viewModel.identities.filter { !$0.authenticated && !$0.pending })
section(title: "identities.pending",
identities: viewModel.identities.filter { $0.pending })
}
.navigationTitle(Text("secondary-navigation.accounts"))
.toolbar {
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
EditButton()
}
}
} }
} }
private extension IdentitiesView {
@ViewBuilder
func section(title: LocalizedStringKey, identities: [Identity]) -> some View {
if identities.isEmpty {
EmptyView()
} else {
Section(header: Text(title)) {
List {
ForEach(identities) { identity in
Button {
withAnimation {
rootViewModel.identitySelected(id: identity.id)
}
} label: {
row(identity: identity)
}
.disabled(identity.id == viewModel.currentIdentityId)
.buttonStyle(PlainButtonStyle())
}
.onDelete {
guard let index = $0.first else { return }
rootViewModel.deleteIdentity(id: identities[index].id)
}
}
}
}
}
@ViewBuilder
func row(identity: Identity) -> some View {
HStack {
Label {
Text(verbatim: identity.handle)
} icon: {
KFImage(identity.image)
.downsampled(dimension: .barButtonItemDimension, scaleFactor: displayScale)
}
Spacer()
if identity.id == viewModel.currentIdentityId {
Image(systemName: "checkmark.circle")
}
}
}
}
#if DEBUG
import PreviewViewModels
struct IdentitiesView_Previews: PreviewProvider {
static var previews: some View {
IdentitiesView(viewModel: .init(identityContext: .preview))
.environmentObject(RootViewModel.preview)
}
}
#endif

View File

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

View File

@ -0,0 +1,14 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class IdentityTableViewCell: UITableViewCell {
var viewModel: IdentityViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = IdentityContentConfiguration(viewModel: viewModel)
}
}

106
Views/IdentityView.swift Normal file
View File

@ -0,0 +1,106 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class IdentityView: UIView {
let imageView = AnimatedImageView()
let nameLabel = UILabel()
let secondaryLabel = UILabel()
private var identityConfiguration: IdentityContentConfiguration
init(configuration: IdentityContentConfiguration) {
identityConfiguration = configuration
super.init(frame: .zero)
initialSetup()
applyIdentityConfiguration()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension IdentityView: UIContentView {
var configuration: UIContentConfiguration {
get { identityConfiguration }
set {
guard let identityConfiguration = newValue as? IdentityContentConfiguration else { return }
self.identityConfiguration = identityConfiguration
applyIdentityConfiguration()
}
}
}
private extension IdentityView {
func initialSetup() {
addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.cornerRadius = .avatarDimension / 2
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .compactSpacing
stackView.addArrangedSubview(nameLabel)
nameLabel.adjustsFontForContentSizeCategory = true
nameLabel.font = .preferredFont(forTextStyle: .headline)
nameLabel.numberOfLines = 0
stackView.addArrangedSubview(secondaryLabel)
secondaryLabel.adjustsFontForContentSizeCategory = true
secondaryLabel.font = .preferredFont(forTextStyle: .subheadline)
secondaryLabel.numberOfLines = 0
secondaryLabel.textColor = .secondaryLabel
let imageViewHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: .avatarDimension)
imageViewHeightConstraint.priority = .justBelowMax
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
imageViewHeightConstraint,
imageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
imageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
imageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: .defaultSpacing),
stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}
func applyIdentityConfiguration() {
let viewModel = identityConfiguration.viewModel
imageView.kf.setImage(with: viewModel.identity.image)
imageView.autoPlayAnimatedImage = viewModel.identityContext.appPreferences.animateAvatars == .everywhere
if let displayName = viewModel.identity.account?.displayName,
!displayName.isEmpty {
let mutableName = NSMutableAttributedString(string: displayName)
if let emojis = viewModel.identity.account?.emojis {
mutableName.insert(emojis: emojis, view: nameLabel)
mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight)
}
nameLabel.attributedText = mutableName
} else {
nameLabel.isHidden = true
}
secondaryLabel.text = viewModel.identity.handle
}
}

View File

@ -31,7 +31,6 @@ private extension SecondaryNavigationTitleView {
addSubview(avatarImageView) addSubview(avatarImageView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.layer.cornerRadius = .barButtonItemDimension / 2 avatarImageView.layer.cornerRadius = .barButtonItemDimension / 2
avatarImageView.autoPlayAnimatedImage = viewModel.identityContext.appPreferences.animateAvatars == .everywhere
avatarImageView.contentMode = .scaleAspectFill avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true avatarImageView.clipsToBounds = true
@ -67,6 +66,7 @@ private extension SecondaryNavigationTitleView {
func applyViewModel() { func applyViewModel() {
avatarImageView.kf.setImage(with: viewModel.identityContext.identity.image) avatarImageView.kf.setImage(with: viewModel.identityContext.identity.image)
avatarImageView.autoPlayAnimatedImage = viewModel.identityContext.appPreferences.animateAvatars == .everywhere
if let displayName = viewModel.identityContext.identity.account?.displayName, if let displayName = viewModel.identityContext.identity.account?.displayName,
!displayName.isEmpty { !displayName.isEmpty {

View File

@ -24,7 +24,7 @@ struct SecondaryNavigationView: View {
} }
} }
NavigationLink( NavigationLink(
destination: IdentitiesView(viewModel: .init(identityContext: viewModel.identityContext)) destination: IdentitiesView { .init(identityContext: viewModel.identityContext) }
.environmentObject(rootViewModel)) { .environmentObject(rootViewModel)) {
Label("secondary-navigation.accounts", systemImage: "rectangle.stack.person.crop") Label("secondary-navigation.accounts", systemImage: "rectangle.stack.person.crop")
} }