diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 93ae2ba09..acafa5fb2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -74,6 +74,9 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; + 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; }; + 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; }; + 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; }; 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; @@ -489,6 +492,9 @@ 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = ""; }; + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = ""; }; + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = ""; }; 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; @@ -1020,6 +1026,14 @@ path = Button; sourceTree = ""; }; + 2D4AD89A2631659400613EFC /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 2D59819925E4A55C000FB903 /* ConfirmEmail */ = { isa = PBXGroup; children = ( @@ -1116,6 +1130,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, @@ -1170,6 +1185,7 @@ children = ( 2D7631B225C159F700929FB9 /* Item.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, 2D7867182625B77500211898 /* NotificationItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, @@ -1193,6 +1209,7 @@ children = ( 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2D4AD89A2631659400613EFC /* CollectionViewCell */, 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, ); path = SuggestionAccount; @@ -2427,6 +2444,7 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, @@ -2554,6 +2572,7 @@ DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, + 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, @@ -2618,6 +2637,7 @@ DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, + 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift new file mode 100644 index 000000000..2e85efc16 --- /dev/null +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -0,0 +1,38 @@ +// +// SelectedAccountItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import CoreData + +enum SelectedAccountItem { + case accountObjectID(accountObjectID: NSManagedObjectID) + case placeHolder(uuid: UUID) +} + +extension SelectedAccountItem: Equatable { + static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool { + switch (lhs, rhs) { + case (.accountObjectID(let idLeft), .accountObjectID(let idRight)): + return idLeft == idRight + case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)): + return uuidLeft == uuidRight + default: + return false + } + } +} + +extension SelectedAccountItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .accountObjectID(let id): + hasher.combine(id) + case .placeHolder(let id): + hasher.combine(id.uuidString) + } + } +} diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift new file mode 100644 index 000000000..0efd9aebc --- /dev/null +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -0,0 +1,35 @@ +// +// SelectedAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +enum SelectedAccountSection: Equatable, Hashable { + case main +} + +extension SelectedAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + managedObjectContext: NSManagedObjectContext + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell + switch item { + case .accountObjectID(let objectID): + let user = managedObjectContext.object(with: objectID) as! MastodonUser + cell.config(with: user) + case .placeHolder( _): + cell.configAsPlaceHolder() + } + return cell + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ce9e33e2b..35111dde1 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -69,6 +69,7 @@ internal enum Asset { internal static let highlight = ColorAsset(name: "Colors/Label/highlight") internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") + internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary") } internal enum Notification { internal static let favourite = ColorAsset(name: "Colors/Notification/favourite") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json new file mode 100644 index 000000000..d4f558bfd --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "67", + "green" : "60", + "red" : "60" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 26ef485ee..717519464 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -125,7 +125,6 @@ final class HomeTimelineViewModel: NSObject { .store(in: &disposeBag) homeTimelineNeedRefresh - .debounce(for: 0.3, scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index c74560386..948d22b0f 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -9,7 +9,7 @@ import UIKit final class ProfileRelationshipActionButton: RoundedEdgesButton { - let actvityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) activityIndicatorView.color = .white return activityIndicatorView @@ -31,15 +31,15 @@ extension ProfileRelationshipActionButton { private func _init() { titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) - actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false - addSubview(actvityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) NSLayoutConstraint.activate([ - actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), - actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) - actvityIndicatorView.hidesWhenStopped = true - actvityIndicatorView.stopAnimating() + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.stopAnimating() } } @@ -52,13 +52,13 @@ extension ProfileRelationshipActionButton { setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - actvityIndicatorView.stopAnimating() + activityIndicatorView.stopAnimating() if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { isEnabled = false } else if actionOptionSet.contains(.updating) { isEnabled = false - actvityIndicatorView.startAnimating() + activityIndicatorView.startAnimating() } else { isEnabled = true } diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift new file mode 100644 index 000000000..bb4e422a4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -0,0 +1,60 @@ +// +// SuggestionAccountCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import UIKit +import CoreDataStack + +class SuggestionAccountCollectionViewCell: UICollectionViewCell { + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + imageView.image = UIImage.placeholder(color: .systemFill) + return imageView + }() + + func configAsPlaceHolder() { + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.image = UIImage.placeholder(color: .systemFill) + } + func config(with mastodonUser: MastodonUser) { + imageView.af.setImage( + withURL: URL(string: mastodonUser.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountCollectionViewCell { + + private func configure() { + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 9cc6f33a2..f36dc8da9 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -46,13 +46,16 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { return label }() - let avatarStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .equalSpacing - stackView.alignment = .center - stackView.spacing = 15 - return stackView + let selectedCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view }() deinit { @@ -70,6 +73,7 @@ extension SuggestionAccountViewController { target: self, action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + tableView.delegate = self tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -85,6 +89,8 @@ extension SuggestionAccountViewController { delegate: self ) + viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -94,6 +100,17 @@ extension SuggestionAccountViewController { .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) + viewModel.headerPlaceholderCount = avatarImageViewCount + viewModel.applySelectedCollectionViewDataSource(accounts: []) + } func setupHeader(accounts: [NSManagedObjectID]) { if accounts.isEmpty { return @@ -106,56 +123,89 @@ extension SuggestionAccountViewController { tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), ]) - avatarStackView.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(avatarStackView) + selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(selectedCollectionView) NSLayoutConstraint.activate([ - avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), - avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), - avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), ]) - let avatarImageViewHeight: Double = 56 - let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) - let count = min(avatarImageViewCount, accounts.count) - for i in 0 ..< count { - let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser - let imageView = UIImageView() - imageView.layer.cornerRadius = 6 - imageView.clipsToBounds = true - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - ]) - if let url = account.avatarImageURL() { - imageView.af.setImage( - withURL: url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - avatarStackView.addArrangedSubview(imageView) - } + selectedCollectionView.delegate = self tableView.tableHeaderView = tableHeader } } -extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { - let selected = !sender.isSelected - sender.isSelected = !sender.isSelected - if selected { - viewModel.selectedAccounts.append(objectID) - } else { - viewModel.selectedAccounts.removeAll { $0 == objectID } +extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return 15 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: 56, height: 56) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .accountObjectID(let accountObjectID): + let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + default: + break } } } +extension SuggestionAccountViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { + let selected = !viewModel.selectedAccounts.contains(objectID) + cell.startAnimating() + viewModel.followAction(objectID: objectID)? + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + cell.stopAnimating() + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + if selected { + self.viewModel.selectedAccounts.append(objectID) + } else { + self.viewModel.selectedAccounts.removeAll { $0 == objectID } + } + cell.button.isSelected = selected + self.viewModel.selectedAccountsDidChange.send() + } + }, receiveValue: { relationShip in + }) + .store(in: &disposeBag) + } +} + extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - viewModel.followAction() + if viewModel.selectedAccounts.count > 0 { + viewModel.delegate?.homeTimelineNeedRefresh.send() + } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index ac8803188..e08e820e4 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -27,16 +27,20 @@ final class SuggestionAccountViewModel: NSObject { // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) var selectedAccounts = [NSManagedObjectID]() + let selectedAccountsDidChange = PassthroughSubject() + var headerPlaceholderCount: Int? var suggestionAccountsFallback = PassthroughSubject() var diffableDataSource: UITableViewDiffableDataSource? { didSet(value) { if !accounts.value.isEmpty { - applyDataSource(accounts: accounts.value) + applyTableViewDataSource(accounts: accounts.value) } } } + var collectionDiffableDataSource: UICollectionViewDiffableDataSource? + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { self.context = context @@ -45,7 +49,8 @@ final class SuggestionAccountViewModel: NSObject { self.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in - self?.applyDataSource(accounts: accounts) + self?.applyTableViewDataSource(accounts: accounts) + self?.applySelectedCollectionViewDataSource(accounts: []) } .store(in: &disposeBag) @@ -53,6 +58,13 @@ final class SuggestionAccountViewModel: NSObject { self.accounts.value = accounts } + selectedAccountsDidChange + .sink { [weak self] _ in + if let selectedAccout = self?.selectedAccounts { + self?.applySelectedCollectionViewDataSource(accounts: selectedAccout) + } + } + .store(in: &disposeBag) if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -102,13 +114,30 @@ final class SuggestionAccountViewModel: NSObject { .store(in: &disposeBag) } - func applyDataSource(accounts: [NSManagedObjectID]) { + func applyTableViewDataSource(accounts: [NSManagedObjectID]) { guard let dataSource = diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(accounts, toSection: .main) dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } + + func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { + guard let count = headerPlaceholderCount else { return } + guard let dataSource = collectionDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let placeholderCount = count - accounts.count + let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } + snapshot.appendItems(accountItems, toSection: .main) + + if placeholderCount > 0 { + for _ in 0 ..< placeholderCount { + snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } func receiveAccounts(ids: [String]) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -135,25 +164,14 @@ final class SuggestionAccountViewModel: NSObject { } } - func followAction() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - for objectID in selectedAccounts { - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - context.apiService.toggleFollow( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: false - ) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - self.delegate?.homeTimelineNeedRefresh.send() - } - } receiveValue: { _ in - } - .store(in: &disposeBag) - } + func followAction(objectID: NSManagedObjectID) -> AnyPublisher, Error>? { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + return context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: false + ) } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 8f564ec31..d81d8e0e0 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -13,7 +13,7 @@ import MastodonSDK import UIKit protocol SuggestionAccountTableViewCellDelegate: AnyObject { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) } final class SuggestionAccountTableViewCell: UITableViewCell { @@ -43,7 +43,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { return label }() - lazy var button: HighlightDimmableButton = { + let buttonContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + let button: HighlightDimmableButton = { let button = HighlightDimmableButton(type: .custom) if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { button.setImage(plusImage, for: .normal) @@ -53,6 +59,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { } return button }() + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() override func prepareForReuse() { super.prepareForReuse() @@ -112,8 +125,25 @@ extension SuggestionAccountTableViewCell { containerStackView.addArrangedSubview(textStackView) textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(buttonContainer) + NSLayoutConstraint.activate([ + buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1), + buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(button) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addSubview(button) + buttonContainer.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor), + buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) } func config(with account: MastodonUser, isSelected: Bool) { @@ -130,7 +160,7 @@ extension SuggestionAccountTableViewCell { button.publisher(for: .touchUpInside) .sink { [weak self] _ in guard let self = self else { return } - self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self) } .store(in: &disposeBag) button.publisher(for: \.isSelected) @@ -141,6 +171,23 @@ extension SuggestionAccountTableViewCell { self?.button.tintColor = Asset.Colors.Label.secondary.color } } - .store(in: &self.disposeBag) + .store(in: &disposeBag) + activityIndicatorView.publisher(for: \.isHidden) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + self?.button.isHidden = !isHidden + } + .store(in: &disposeBag) + + } + + func startAnimating() { + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + activityIndicatorView.isHidden = true } }