From 1c295a1f5aca14e5485cca6e92ff821c4aeff3ee Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Thu, 28 Jan 2021 15:15:22 -0800 Subject: [PATCH] Re-write identity management in UIKit --- Data Sources/IdentitiesDataSource.swift | 127 ++++++++++++++++++ Metatext.xcodeproj/project.pbxproj | 20 +++ .../IdentitiesViewController.swift | 63 +++++++++ .../View Models/IdentitiesViewModel.swift | 9 +- .../View Models/IdentityViewModel.swift | 13 ++ Views/IdentitiesView.swift | 92 ++----------- Views/IdentityContentConfiguration.swift | 18 +++ Views/IdentityTableViewCell.swift | 14 ++ Views/IdentityView.swift | 106 +++++++++++++++ Views/SecondaryNavigationTitleView.swift | 2 +- Views/SecondaryNavigationView.swift | 2 +- 11 files changed, 378 insertions(+), 88 deletions(-) create mode 100644 Data Sources/IdentitiesDataSource.swift create mode 100644 View Controllers/IdentitiesViewController.swift create mode 100644 ViewModels/Sources/ViewModels/View Models/IdentityViewModel.swift create mode 100644 Views/IdentityContentConfiguration.swift create mode 100644 Views/IdentityTableViewCell.swift create mode 100644 Views/IdentityView.swift diff --git a/Data Sources/IdentitiesDataSource.swift b/Data Sources/IdentitiesDataSource.swift new file mode 100644 index 0000000..5828939 --- /dev/null +++ b/Data Sources/IdentitiesDataSource.swift @@ -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 { + private let updateQueue = + DispatchQueue(label: "com.metabolist.metatext.identities-data-source.update-queue") + private var cancellables = Set() + 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, + 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() + 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) + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index ac421d3..cabc51a 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -21,6 +21,11 @@ D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; }; D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.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 */; }; D035F86925B7F2ED00DC75ED /* MainNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86825B7F2ED00DC75ED /* MainNavigationViewController.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 = ""; }; D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = ""; }; + D021A5F525C34538008A0C0D /* IdentitiesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesViewController.swift; sourceTree = ""; }; + D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesDataSource.swift; sourceTree = ""; }; + D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityTableViewCell.swift; sourceTree = ""; }; + D021A61325C36BFB008A0C0D /* IdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityView.swift; sourceTree = ""; }; + D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityContentConfiguration.swift; sourceTree = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewController.swift; sourceTree = ""; }; D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationView.swift; sourceTree = ""; }; @@ -478,6 +488,7 @@ D0A1F4F5252E7D2A004435BF /* Data Sources */ = { isa = PBXGroup; children = ( + D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */, D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, ); path = "Data Sources"; @@ -522,6 +533,9 @@ D07EC7F125B13E57006DF726 /* EmojiView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, + D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */, + D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */, + D021A61325C36BFB008A0C0D /* IdentityView.swift */, D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, @@ -570,6 +584,7 @@ D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */, D087671525BAA8C0001FDD43 /* ExploreViewController.swift */, + D021A5F525C34538008A0C0D /* IdentitiesViewController.swift */, D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, @@ -851,6 +866,7 @@ D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */, D00702292555E51200F38136 /* ConversationListCell.swift in Sources */, D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, + D021A5F625C34538008A0C0D /* IdentitiesViewController.swift in Sources */, D07EC7DC25B13DBB006DF726 /* EmojiCollectionViewCell.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, @@ -881,6 +897,7 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, + D021A60A25C36B32008A0C0D /* IdentityTableViewCell.swift in Sources */, D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */, D0F4362D25C10B9600E4F896 /* AddIdentityViewController.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, @@ -889,6 +906,7 @@ D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D07EC7E325B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, + D021A61A25C36C1A008A0C0D /* IdentityContentConfiguration.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, @@ -905,6 +923,7 @@ D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, + D021A61425C36BFB008A0C0D /* IdentityView.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */, D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */, @@ -914,6 +933,7 @@ D0EA59402522AC8700804347 /* CardView.swift in Sources */, D0F0B10E251A868200942152 /* AccountView.swift in Sources */, D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */, + D021A60025C3478F008A0C0D /* IdentitiesDataSource.swift in Sources */, D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */, diff --git a/View Controllers/IdentitiesViewController.swift b/View Controllers/IdentitiesViewController.swift new file mode 100644 index 0000000..586695a --- /dev/null +++ b/View Controllers/IdentitiesViewController.swift @@ -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) + } + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/IdentitiesViewModel.swift b/ViewModels/Sources/ViewModels/View Models/IdentitiesViewModel.swift index bcfef46..4b9d8c3 100644 --- a/ViewModels/Sources/ViewModels/View Models/IdentitiesViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/IdentitiesViewModel.swift @@ -5,7 +5,6 @@ import Foundation import ServiceLayer public final class IdentitiesViewModel: ObservableObject { - public let currentIdentityId: Identity.Id @Published public private(set) var identities = [Identity]() @Published public var alertItem: AlertItem? public let identityContext: IdentityContext @@ -14,10 +13,16 @@ public final class IdentitiesViewModel: ObservableObject { public init(identityContext: IdentityContext) { self.identityContext = identityContext - currentIdentityId = identityContext.identity.id identityContext.service.identitiesPublisher() + .receive(on: RunLoop.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$identities) } } + +public extension IdentitiesViewModel { + func viewModel(identity: Identity) -> IdentityViewModel { + .init(identity: identity, identityContext: identityContext) + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/IdentityViewModel.swift b/ViewModels/Sources/ViewModels/View Models/IdentityViewModel.swift new file mode 100644 index 0000000..ab7ab54 --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/IdentityViewModel.swift @@ -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 + } +} diff --git a/Views/IdentitiesView.swift b/Views/IdentitiesView.swift index aee8d03..257c669 100644 --- a/Views/IdentitiesView.swift +++ b/Views/IdentitiesView.swift @@ -4,91 +4,15 @@ import Kingfisher import SwiftUI import ViewModels -struct IdentitiesView: View { - @StateObject var viewModel: IdentitiesViewModel +struct IdentitiesView: UIViewControllerRepresentable { + let viewModelClosure: () -> IdentitiesViewModel @EnvironmentObject var rootViewModel: RootViewModel - @Environment(\.displayScale) var displayScale: CGFloat - var body: some View { - Form { - Section { - NavigationLink( - destination: AddIdentityView( - 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() - } - } + func makeUIViewController(context: Context) -> IdentitiesViewController { + IdentitiesViewController(viewModel: viewModelClosure(), rootViewModel: rootViewModel) + } + + func updateUIViewController(_ uiViewController: IdentitiesViewController, context: Context) { + } } - -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 diff --git a/Views/IdentityContentConfiguration.swift b/Views/IdentityContentConfiguration.swift new file mode 100644 index 0000000..6426fc8 --- /dev/null +++ b/Views/IdentityContentConfiguration.swift @@ -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 + } +} diff --git a/Views/IdentityTableViewCell.swift b/Views/IdentityTableViewCell.swift new file mode 100644 index 0000000..8e4b2ea --- /dev/null +++ b/Views/IdentityTableViewCell.swift @@ -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) + } +} diff --git a/Views/IdentityView.swift b/Views/IdentityView.swift new file mode 100644 index 0000000..08eaecd --- /dev/null +++ b/Views/IdentityView.swift @@ -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 + } +} diff --git a/Views/SecondaryNavigationTitleView.swift b/Views/SecondaryNavigationTitleView.swift index d994c0c..118e9cc 100644 --- a/Views/SecondaryNavigationTitleView.swift +++ b/Views/SecondaryNavigationTitleView.swift @@ -31,7 +31,6 @@ private extension SecondaryNavigationTitleView { addSubview(avatarImageView) avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.layer.cornerRadius = .barButtonItemDimension / 2 - avatarImageView.autoPlayAnimatedImage = viewModel.identityContext.appPreferences.animateAvatars == .everywhere avatarImageView.contentMode = .scaleAspectFill avatarImageView.clipsToBounds = true @@ -67,6 +66,7 @@ private extension SecondaryNavigationTitleView { func applyViewModel() { avatarImageView.kf.setImage(with: viewModel.identityContext.identity.image) + avatarImageView.autoPlayAnimatedImage = viewModel.identityContext.appPreferences.animateAvatars == .everywhere if let displayName = viewModel.identityContext.identity.account?.displayName, !displayName.isEmpty { diff --git a/Views/SecondaryNavigationView.swift b/Views/SecondaryNavigationView.swift index 0f48fa3..7dfc5d5 100644 --- a/Views/SecondaryNavigationView.swift +++ b/Views/SecondaryNavigationView.swift @@ -24,7 +24,7 @@ struct SecondaryNavigationView: View { } } NavigationLink( - destination: IdentitiesView(viewModel: .init(identityContext: viewModel.identityContext)) + destination: IdentitiesView { .init(identityContext: viewModel.identityContext) } .environmentObject(rootViewModel)) { Label("secondary-navigation.accounts", systemImage: "rectangle.stack.person.crop") }