From 458ab6bcdaf1bf4bfd381d60d4cbbb9ad348fbe6 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 1 Apr 2021 20:54:57 +0800 Subject: [PATCH] feature: search recommend page --- Localization/app.json | 13 ++ Mastodon.xcodeproj/project.pbxproj | 24 +++ .../Section/RecomendHashTagSection.swift | 26 +++ .../Section/RecommendAccountSection.swift | 26 +++ Mastodon/Extension/UIView+Constraint.swift | 88 +++++----- Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 22 +++ .../Background/search.colorset/Contents.json | 20 +++ .../Resources/en.lproj/Localizable.strings | 7 + ...hRecommendAccountsCollectionViewCell.swift | 152 ++++++++++++++++++ ...earchRecommendTagsCollectionViewCell.swift | 54 ++++++- .../SearchViewController+recomendView.swift | 124 +++++++++++--- .../Scene/Search/SearchViewController.swift | 80 ++++++++- Mastodon/Scene/Search/SearchViewModel.swift | 73 ++++++--- .../SearchRecommendCollectionHeader.swift | 89 ++++++++++ .../Entity/Mastodon+Entity+Account.swift | 12 +- .../Entity/Mastodon+Entity+History.swift | 2 +- .../Entity/Mastodon+Entity+Tag.swift | 6 +- 18 files changed, 710 insertions(+), 109 deletions(-) create mode 100644 Mastodon/Diffiable/Section/RecomendHashTagSection.swift create mode 100644 Mastodon/Diffiable/Section/RecommendAccountSection.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json create mode 100644 Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift create mode 100644 Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift diff --git a/Localization/app.json b/Localization/app.json index 94b785f2c..433515fef 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -241,6 +241,19 @@ "searchBar": { "placeholder": "Search hashtags and users", "cancel": "Cancel" + }, + "recommend": { + "buttonText": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "Except for Sam, you will not like his account.", + "follow": "Follow" + } } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 773c869b1..19228f2e7 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -94,6 +94,10 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */; }; + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; @@ -401,6 +405,10 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = ""; }; + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; @@ -718,6 +726,7 @@ isa = PBXGroup; children = ( 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -871,6 +880,8 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, ); @@ -936,6 +947,14 @@ path = Decoration; sourceTree = ""; }; + 2DE0FAC62615F5D200CDF649 /* View */ = { + isa = PBXGroup; + children = ( + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; 2DF75BB725D1473400694EC8 /* Stack */ = { isa = PBXGroup; children = ( @@ -1418,6 +1437,7 @@ DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( + 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, @@ -1896,6 +1916,7 @@ DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, @@ -1911,6 +1932,7 @@ 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, @@ -1939,6 +1961,7 @@ DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, @@ -1983,6 +2006,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift new file mode 100644 index 000000000..2f78e73b9 --- /dev/null +++ b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift @@ -0,0 +1,26 @@ +// +// RecomendHashTagSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecomendHashTagSection: Equatable, Hashable { + case main +} + +extension RecomendHashTagSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell + cell.config(with: tag) + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift new file mode 100644 index 000000000..b08c9abab --- /dev/null +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -0,0 +1,26 @@ +// +// RecommendAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecommendAccountSection: Equatable, Hashable { + case main +} + +extension RecommendAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, account -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell + cell.config(with: account) + return cell + } + } +} diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift index 42d3bfd93..baa923ada 100644 --- a/Mastodon/Extension/UIView+Constraint.swift +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -42,17 +42,17 @@ extension UIView { attribute: .top, multiplier: 1.0, constant: toSuperviewEdges?.top ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .trailing, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .trailing, multiplier: 1.0, constant: toSuperviewEdges?.right ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .bottom, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .bottom, multiplier: 1.0, constant: toSuperviewEdges?.bottom ?? 0.0) @@ -89,40 +89,6 @@ extension UIView { constant: constant) } - func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant) - } - - func pinToBottom(to: UIView, height: CGFloat) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: to), - constraint(toBottom: to, constant: 0.0), - constraint(.height, constant: height) - ]) - } - - func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant) - } - - func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant) - } - - func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant) - } - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -204,17 +170,6 @@ extension UIView { ]) } - func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: viewAbove), - constraint(toBottom: viewAbove, constant: padding), - self.centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor), - height != nil ? constraint(.height, constant: height!) : nil - ]) - } - func pin(toSize: CGSize) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -223,6 +178,25 @@ extension UIView { heightAnchor.constraint(equalToConstant: toSize.height)]) } + func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + var constraints = [NSLayoutConstraint]() + if let topConstant = top { + constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant)) + } + if let leftConstant = left { + constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant)) + } + if let bottomConstant = bottom { + constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant)) + } + if let rightConstant = right { + constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant)) + } + constrain(constraints) + + } func pinTopLeft(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -231,6 +205,14 @@ extension UIView { topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + func pinTopLeft(top: CGFloat, left: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } + func pinTopRight(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -238,6 +220,14 @@ extension UIView { view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + + func pinTopRight(top: CGFloat, right: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } func pinTopLeft(toView: UIView, topPadding: CGFloat) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 82e7f8b1f..cbb5c2940 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -42,6 +42,7 @@ internal enum Asset { internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let search = ColorAsset(name: "Colors/Background/search") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let success = ColorAsset(name: "Colors/Background/success") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1294c78e2..98f08ef06 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -370,6 +370,28 @@ internal enum L10n { } } internal enum Search { + internal enum Recommend { + /// See All + internal static let buttontext = L10n.tr("Localizable", "Scene.Search.Recommend.Buttontext") + internal enum Accounts { + /// Except for Sam, you will not like his account. + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") + /// Follow + internal static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") + /// Accounts you might like + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title") + } + internal enum HashTag { + /// Hashtags that are getting quite a bit of attention among people you follow + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description") + /// %@ people are talking + internal static func peopleTalking(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1)) + } + /// Trending in your timeline + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title") + } + } internal enum Searchbar { /// Cancel internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json new file mode 100644 index 000000000..838e44e44 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "232", + "green" : "225", + "red" : "217" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e229d0f48..db808b56d 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -115,6 +115,13 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.Buttontext" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; "Scene.Search.Searchbar.Cancel" = "Cancel"; "Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; "Scene.ServerPicker.Button.Category.All" = "All"; diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift new file mode 100644 index 000000000..bed61f228 --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -0,0 +1,152 @@ +// +// SearchRecommendAccountsCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit +import MastodonSDK + +class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { + let avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let headerImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let displayNameLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 18, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let acctLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .preferredFont(forTextStyle: .body) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let followButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(.white, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + button.layer.cornerRadius = 12 + button.layer.borderWidth = 3 + button.layer.borderColor = UIColor.white.cgColor + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + headerImageView.af.cancelImageRequest() + avatarImageView.af.cancelImageRequest() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendAccountsCollectionViewCell { + private func configure() { + headerImageView.backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + + contentView.addSubview(headerImageView) + headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0 ) + + contentView.addSubview(avatarImageView) + avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) + avatarImageView.constrain([ + avatarImageView.constraint(.top, toView: contentView), + avatarImageView.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(displayNameLabel) + displayNameLabel.constrain([ + displayNameLabel.constraint(.top, toView: contentView,constant: 108), + displayNameLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(acctLabel) + acctLabel.constrain([ + acctLabel.constraint(.top, toView: contentView,constant: 132), + acctLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(followButton) + followButton.pin(toSize: CGSize(width: 76, height: 24)) + followButton.constrain([ + followButton.constraint(.top, toView: contentView,constant: 159), + followButton.constraint(.centerX, toView: contentView) + ]) + } + + func config(with account: Mastodon.Entity.Account) { + displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName + acctLabel.text = account.acct + avatarImageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + headerImageView.af.setImage( + withURL: URL(string: account.header)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendAccountsCollectionViewCell() + cell.avatarImageView.backgroundColor = .white + cell.headerImageView.backgroundColor = .red + cell.displayNameLabel.text = "sunxiaojian" + cell.acctLabel.text = "sunxiaojian@mastodon.online" + return cell + } + .previewLayout(.fixed(width: 257, height: 202)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 108f2b6a2..adc9bd098 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -7,6 +7,7 @@ import Foundation import UIKit +import MastodonSDK class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let backgroundImageView: UIImageView = { @@ -18,8 +19,9 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let hashTagTitleLabel: UILabel = { let label = UILabel() label.textColor = .white - label.font = .preferredFont(forTextStyle: .caption1) + label.font = .systemFont(ofSize: 20, weight: .semibold) label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail return label }() @@ -57,20 +59,60 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { extension SearchRecommendTagsCollectionViewCell { private func configure() { + backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + contentView.addSubview(backgroundImageView) backgroundImageView.constrain(toSuperviewEdges: nil) contentView.addSubview(hashTagTitleLabel) - hashTagTitleLabel.pinTopLeft(padding: 16) + hashTagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) contentView.addSubview(peopleLabel) - peopleLabel.constrain([ - peopleLabel.constraint(toTop: contentView, constant: 46), - peopleLabel.constraint(toLeading: contentView, constant: 16) - ]) + peopleLabel.pinTopLeft(top: 46, left: 16) contentView.addSubview(flameIconView) flameIconView.pinTopRight(padding: 16) } + + func config(with tag: Mastodon.Entity.Tag) { + hashTagTitleLabel.text = "# " + tag.name + if let peopleAreTalking = tag.history?.compactMap({ Int($0.uses)}).reduce(0, +) { + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + peopleLabel.text = string + } else { + peopleLabel.text = "" + } + } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendTagsCollectionViewCell() + cell.hashTagTitleLabel.text = "# test" + cell.peopleLabel.text = "128 people are talking" + return cell + } + .previewLayout(.fixed(width: 228, height: 130)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+recomendView.swift index b498aa608..b62ebed7e 100644 --- a/Mastodon/Scene/Search/SearchViewController+recomendView.swift +++ b/Mastodon/Scene/Search/SearchViewController+recomendView.swift @@ -1,5 +1,5 @@ // -// SearchViewController+recommendView.swift +// SearchViewController+hashTagCollectionView.swift // Mastodon // // Created by sxiaojian on 2021/3/31. @@ -7,43 +7,121 @@ import Foundation import UIKit - +import OSLog +import MastodonSDK extension SearchViewController { - func setuprecommendView() { - recommendView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) - recommendView.dataSource = self - recommendView.delegate = self + func setupHashTagCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.HashTag.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashTagSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + hashTagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) + hashTagCollectionView.delegate = self + + stackView.addArrangedSubview(hashTagCollectionView) + hashTagCollectionView.constrain([ + hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + ]) + + viewModel.requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendHashTags.isEmpty { + let dataSource = RecomendHashTagSection.collectionViewDiffableDataSource(for: self.hashTagCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.hashTagDiffableDataSource = dataSource + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) } + func setupAccountsCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.Accounts.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.Accounts.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.accountSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) + accountsCollectionView.delegate = self + + stackView.addArrangedSubview(accountsCollectionView) + accountsCollectionView.constrain([ + accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) + ]) + + viewModel.requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendAccounts.isEmpty { + let dataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: self.accountsCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.accountDiffableDataSource = dataSource + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - recommendView.collectionViewLayout.invalidateLayout() + hashTagCollectionView.collectionViewLayout.invalidateLayout() + accountsCollectionView.collectionViewLayout.invalidateLayout() } } extension SearchViewController: UICollectionViewDelegate { - + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + } } -extension SearchViewController: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return (self.viewModel.recommendAccounts.isEmpty ? 0 : 1) + (self.viewModel.recommendHashTags.isEmpty ? 0 : 1) +// MARK: - UICollectionViewDelegateFlowLayout +extension SearchViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - switch section { - case 0: - return viewModel.recommendHashTags.count - case 1: - return viewModel.recommendAccounts.count - default: - return 0 + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + if collectionView == hashTagCollectionView { + return 6 + } else { + return 12 } } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - return UICollectionViewCell() + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + if collectionView == hashTagCollectionView { + return CGSize(width: 228, height: 130) + } else { + return CGSize(width: 257, height: 202) + } + } - +} + +extension SearchViewController { + @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) { + + } + + @objc func accountSeeAllButtonPressed(_ sender: UIButton) { + + } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f76f596c0..c51350665 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import MastodonSDK final class SearchViewController: UIViewController, NeedsDependency { @@ -27,7 +28,40 @@ final class SearchViewController: UIViewController, NeedsDependency { return searchBar }() - let recommendView: UICollectionView = { + let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = true + scrollView.clipsToBounds = false + return scrollView + }() + + let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 0 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0) + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + let hashTagCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + + + let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) @@ -44,12 +78,35 @@ extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.search.color searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true - viewModel.requestRecommendData() + setupScrollView() + setupHashTagCollectionView() + setupAccountsCollectionView() + + } + func setupScrollView() { + view.addSubview(scrollView) + scrollView.constrain([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) + ]) + + scrollView.addSubview(stackView) + stackView.constrain([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) + } - } extension SearchViewController: UISearchBarDelegate { @@ -79,3 +136,20 @@ extension SearchViewController: UISearchBarDelegate { extension SearchViewController { } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchViewController_Previews: PreviewProvider { + + static var previews: some View { + UIViewControllerPreview { + let viewController = SearchViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 679a2cfaf..40b22c880 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -5,14 +5,13 @@ // Created by sxiaojian on 2021/3/31. // -import Foundation import Combine +import Foundation import MastodonSDK -import UIKit import OSLog +import UIKit final class SearchViewModel { - var disposeBag = Set() // input @@ -25,30 +24,54 @@ final class SearchViewModel { var recommendAccounts = [Mastodon.Entity.Account]() init(context: AppContext) { - self.context = context + self.context = context } - func requestRecommendData() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) - - let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - Publishers.Zip(trendsAPI,accountsAPI) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request success", ((#file as NSString).lastPathComponent), #line, #function) - break - } - } receiveValue: { [weak self] (tags, accounts) in - guard let self = self else { return } - self.recommendAccounts = accounts.value - self.recommendHashTags = tags.value + func requestRecommendHashTags() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return } - .store(in: &disposeBag) + self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] tags in + guard let self = self else { return } + self.recommendHashTags = tags.value + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccounts() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + self.recommendAccounts = accounts.value + } + .store(in: &self.disposeBag) + } } } diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift new file mode 100644 index 000000000..5901b324a --- /dev/null +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -0,0 +1,89 @@ +// +// SearchRecommendCollectionHeader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit + +class SearchRecommendCollectionHeader: UIView { + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = .black + label.font = .systemFont(ofSize: 20, weight: .semibold) + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightSecondaryText.color + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + let seeAllButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendCollectionHeader { + private func configure() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + titleLabel.pinTopLeft(top: 31, left: 16) + + addSubview(descriptionLabel) + descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16)) + + addSubview(seeAllButton) + seeAllButton.pinTopRight(top: 26, right: 16) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendCollectionHeader_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendCollectionHeader() + cell.titleLabel.text = "Trending in your timeline" + cell.descriptionLabel.text = "Hashtags that are getting quite a bit of attention among people you follow" + cell.seeAllButton.setTitle("See All", for: .normal) + return cell + } + .previewLayout(.fixed(width: 320, height: 116)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 82fc9502b..db1913dc8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -17,7 +17,16 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/account/) - public class Account: Codable { + public class Account: Codable, Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { + return lhs.id == rhs.id + } + public typealias ID = String @@ -82,5 +91,6 @@ extension Mastodon.Entity { case muteExpiresAt = "mute_expires_at" } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 9bf1a3a28..4e3a66400 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/history/) - public struct History: Codable { + public struct History: Codable, Hashable { /// UNIX timestamp on midnight of the given day public let day: Date public let uses: String diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 61c47ed68..867ff71a9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -16,7 +16,11 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/tag/) - public struct Tag: Codable { + public struct Tag: Codable, Hashable { + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + } + // Base public let name: String public let url: String