feat: Implement layout for hashtag timeline header view

This commit is contained in:
Marcus Kida 2022-11-23 15:57:51 +01:00
parent 2987bb29fa
commit 178a6e503a
No known key found for this signature in database
GPG Key ID: 19FF64E08013CA40
9 changed files with 295 additions and 3 deletions

View File

@ -24,6 +24,7 @@
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; };
2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; };
2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
@ -522,6 +523,7 @@
164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; };
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; };
2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = "<group>"; };
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
@ -1126,6 +1128,7 @@
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */,
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */,
);
path = HashtagTimeline;
sourceTree = "<group>";
@ -3301,6 +3304,7 @@
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
DB025B78278D606A002F581E /* StatusItem.swift in Sources */,
DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */,
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */,
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,

View File

@ -0,0 +1,109 @@
//
// HashtagTimelineHeaderView.swift
// Mastodon
//
// Created by Marcus Kida on 22.11.22.
//
import UIKit
import MastodonSDK
import MastodonUI
fileprivate extension CGFloat {
static let padding: CGFloat = 16
static let descriptionLabelSpacing: CGFloat = 12
}
final class HashtagTimelineHeaderView: UIView {
let titleLabel = UILabel()
let postCountLabel = UILabel()
let participantsLabel = UILabel()
let postsTodayLabel = UILabel()
let postCountDescLabel = UILabel()
let participantsDescLabel = UILabel()
let postsTodayDescLabel = UILabel()
let followButton: UIButton = {
let button = RoundedEdgesButton(type: .custom)
button.cornerRadius = 10
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
button.backgroundColor = .black
return button
}()
init() {
super.init(frame: .zero)
setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension HashtagTimelineHeaderView {
func setupLayout() {
[titleLabel, postCountLabel, participantsLabel, postsTodayLabel, postCountDescLabel, participantsDescLabel, postsTodayDescLabel, followButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
addSubview($0)
}
// hashtag name / title
titleLabel.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold))
[postCountLabel, participantsLabel, postsTodayLabel].forEach {
$0.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: .systemFont(ofSize: 20, weight: .bold))
$0.text = "999"
}
[postCountDescLabel, participantsDescLabel, postsTodayDescLabel].forEach {
$0.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
}
postCountDescLabel.text = "posts"
participantsDescLabel.text = "participants"
postsTodayDescLabel.text = "posts today"
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: .padding),
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .padding),
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
postCountLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postCountLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
postCountDescLabel.leadingAnchor.constraint(equalTo: postCountLabel.leadingAnchor),
participantsDescLabel.leadingAnchor.constraint(equalTo: postCountDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
participantsLabel.centerXAnchor.constraint(equalTo: participantsDescLabel.centerXAnchor),
participantsLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postsTodayDescLabel.leadingAnchor.constraint(equalTo: participantsDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
postsTodayLabel.centerXAnchor.constraint(equalTo: postsTodayDescLabel.centerXAnchor),
postsTodayLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postCountDescLabel.topAnchor.constraint(equalTo: postCountLabel.bottomAnchor),
participantsDescLabel.topAnchor.constraint(equalTo: participantsLabel.bottomAnchor),
postsTodayDescLabel.topAnchor.constraint(equalTo: postsTodayLabel.bottomAnchor),
postCountDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
participantsDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
postsTodayDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
followButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
followButton.bottomAnchor.constraint(equalTo: postsTodayDescLabel.bottomAnchor),
followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor),
followButton.widthAnchor.constraint(equalToConstant: 84)
])
}
}
extension HashtagTimelineHeaderView {
func update(_ entity: Mastodon.Entity.Tag) {
titleLabel.text = "#\(entity.name)"
followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal)
}
}

View File

@ -15,6 +15,7 @@ import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
import MastodonSDK
final class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -27,6 +28,18 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
var disposeBag = Set<AnyCancellable>()
var viewModel: HashtagTimelineViewModel!
private lazy var headerView: HashtagTimelineHeaderView = {
let headerView = HashtagTimelineHeaderView()
headerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
headerView.heightAnchor.constraint(equalToConstant: 118),
// headerView.widthAnchor.constraint(equalTo: tableView.widthAnchor)
])
return headerView
}()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
@ -114,6 +127,14 @@ extension HashtagTimelineViewController {
self?.updatePromptTitle()
}
.store(in: &disposeBag)
viewModel.hashtagDetails
.receive(on: DispatchQueue.main)
.sink { [weak self] tag in
guard let tag = tag else { return }
self?.updateHeaderView(with: tag)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
@ -148,7 +169,15 @@ extension HashtagTimelineViewController {
subtitle = L10n.Plural.peopleTalking(peopleTalkingNumber)
}
}
}
extension HashtagTimelineViewController {
private func updateHeaderView(with tag: Mastodon.Entity.Tag) {
if tableView.tableHeaderView == nil {
tableView.tableHeaderView = headerView
}
headerView.update(tag)
}
}
extension HashtagTimelineViewController {

View File

@ -23,7 +23,7 @@ final class HashtagTimelineViewModel {
var disposeBag = Set<AnyCancellable>()
var needLoadMiddleIndex: Int? = nil
// input
let context: AppContext
let authContext: AuthContext
@ -32,10 +32,11 @@ final class HashtagTimelineViewModel {
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
let didLoadLatest = PassthroughSubject<Void, Never>()
let hashtagDetails = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
// bottom loader
private(set) lazy var stateMachine: GKStateMachine = {
@ -61,6 +62,7 @@ final class HashtagTimelineViewModel {
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
updateTagInformation()
// end init
}
@ -70,3 +72,15 @@ final class HashtagTimelineViewModel {
}
private extension HashtagTimelineViewModel {
func updateTagInformation() {
Task { @MainActor in
let tag = try? await context.apiService.getTagInformation(
for: hashtag,
authenticationBox: authContext.mastodonAuthenticationBox
).value
self.hashtagDetails.send(tag)
}
}
}

View File

@ -161,3 +161,40 @@ extension APIService {
}
}
extension APIService {
public func getFollowedTags(
domain: String,
query: Mastodon.API.Account.FollowedTagsQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Account.followedTags(
session: session,
domain: domain,
query: query,
authorization: authorization
).singleOutput()
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
for entity in response.value {
_ = Persistence.Tag.createOrMerge(
in: managedObjectContext,
context: Persistence.Tag.PersistContext(
domain: domain,
entity: entity,
me: me,
networkDate: response.networkDate
)
)
}
}
return response
} // end func
}

View File

@ -0,0 +1,49 @@
//
// APIService+Tags.swift
//
//
// Created by Marcus Kida on 23.11.22.
//
import os.log
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
extension APIService {
public func getTagInformation(
for tag: String,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Tags.tag(
session: session,
domain: domain,
tagId: tag,
authorization: authorization
).singleOutput()
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
_ = Persistence.Tag.createOrMerge(
in: managedObjectContext,
context: Persistence.Tag.PersistContext(
domain: domain,
entity: response.value,
me: me,
networkDate: response.networkDate
)
)
}
return response
} // end func
}

View File

@ -28,7 +28,7 @@ extension Mastodon.API.Account {
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `[Tag]` nested in the response
public static func followers(
public static func followedTags(
session: URLSession,
domain: String,
query: FollowedTagsQuery,

View File

@ -0,0 +1,49 @@
//
// Mastodin+API+Tags.swift
//
//
// Created by Marcus Kida on 23.11.22.
//
import Combine
import Foundation
extension Mastodon.API.Tags {
static func tagsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("tags")
}
/// Followed Tags
///
/// View your followed hashtags.
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/tags/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - tagId: The Hashtag
/// - Returns: `AnyPublisher` contains `Tag` nested in the response
public static func tag(
session: URLSession,
domain: String,
tagId: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Tag>, Error> {
let request = Mastodon.API.get(
url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -112,6 +112,7 @@ extension Mastodon.API {
public enum Polls { }
public enum Reblog { }
public enum Statuses { }
public enum Tags {}
public enum Timeline { }
public enum Trends { }
public enum Suggestions { }