feature: search recommend page

This commit is contained in:
sunxiaojian 2021-04-01 20:54:57 +08:00
parent fa14477f7d
commit 458ab6bcda
18 changed files with 710 additions and 109 deletions

View File

@ -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"
}
}
}
}

View File

@ -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 = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = "<group>"; };
2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = "<group>"; };
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; };
@ -718,6 +726,7 @@
isa = PBXGroup;
children = (
2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */,
2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
@ -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 = "<group>";
};
2DE0FAC62615F5D200CDF649 /* View */ = {
isa = PBXGroup;
children = (
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */,
);
path = View;
sourceTree = "<group>";
};
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 */,

View File

@ -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<RecomendHashTagSection, Mastodon.Entity.Tag> {
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
}
}
}

View File

@ -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<RecommendAccountSection, Mastodon.Entity.Account> {
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
}
}
}

View File

@ -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 }

View File

@ -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")

View File

@ -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")

View File

@ -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
}
}

View File

@ -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";

View File

@ -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

View File

@ -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

View File

@ -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<RecomendHashTagSection, Mastodon.Entity.Tag>()
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<RecommendAccountSection, Mastodon.Entity.Account>()
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) {
}
}

View File

@ -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<RecomendHashTagSection, Mastodon.Entity.Tag>?
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, Mastodon.Entity.Account>?
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

View File

@ -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<AnyCancellable>()
// 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<Void, Error> {
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<Void, Error> {
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)
}
}
}

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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