Merge pull request #323 from mastodon/feature/v2-onboarding

Implement v2 Onboarding scene
This commit is contained in:
CMK 2022-01-10 11:31:47 +08:00 committed by GitHub
commit b8181b83c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
148 changed files with 3515 additions and 5700 deletions

View File

@ -7,7 +7,6 @@ set -eo pipefail
xcodebuild -workspace Mastodon.xcworkspace \
-scheme Mastodon \
-disableAutomaticPackageResolution \
-destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
clean \
build | xcpretty
build | xcpretty

View File

@ -15,8 +15,8 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>88</string>
<string>90</string>
</dict>
</plist>

View File

@ -15,8 +15,8 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>88</string>
<string>90</string>
</dict>
</plist>

View File

@ -15,8 +15,8 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>88</string>
<string>90</string>
</dict>
</plist>

View File

@ -193,10 +193,14 @@
},
"scene": {
"welcome": {
"slogan": "Social networking\nback in your hands."
"slogan": "Social networking\nback in your hands.",
"get_started": "Get Started",
"log_in": "Log In"
},
"server_picker": {
"title": "Pick a server,\nany server.",
"title": "Mastodon is made of users in different communities.",
"subtitle": "Pick a community based on your interests, region, or a general purpose one.",
"subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.",
"button": {
"category": {
"all": "All",
@ -223,7 +227,7 @@
"category": "CATEGORY"
},
"input": {
"placeholder": "Find a server or join your own..."
"placeholder": "Search communities"
},
"empty_state": {
"finding_servers": "Finding available servers...",
@ -232,7 +236,7 @@
}
},
"register": {
"title": "Tell us about you.",
"title": "Lets get you set up on %s",
"input": {
"avatar": {
"delete": "Delete"
@ -249,6 +253,12 @@
},
"password": {
"placeholder": "password",
"require": "Your password needs at least:",
"character_limit": "8 characters",
"accessibility": {
"checked": "checked",
"unchecked": "unchecked"
},
"hint": "Your password needs at least eight characters"
},
"invite": {
@ -286,7 +296,7 @@
},
"server_rules": {
"title": "Some ground rules.",
"subtitle": "These rules are set by the admins of %s.",
"subtitle": "These are set and enforced by the %s moderators.",
"prompt": "By continuing, youre subject to the terms of service and privacy policy for %s.",
"terms_of_service": "terms of service",
"privacy_policy": "privacy policy",
@ -296,10 +306,10 @@
},
"confirm_email": {
"title": "One last thing.",
"subtitle": "We just sent an email to %s,\ntap the link to confirm your account.",
"subtitle": "Tap the link we emailed to you to verify your account.",
"button": {
"open_email_app": "Open Email App",
"dont_receive_email": "I never got an email"
"resend": "Resend"
},
"dont_receive_email": {
"title": "Check your email",
@ -554,4 +564,4 @@
"accessibility_hint": "Double tap to dismiss this wizard"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,17 +7,17 @@
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>44</integer>
<integer>26</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>45</integer>
<integer>27</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>2</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -27,7 +27,7 @@
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>1</integer>
</dict>
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
<dict>
@ -102,7 +102,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>43</integer>
<integer>25</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -117,12 +117,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>7</integer>
<integer>3</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>42</integer>
<integer>24</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": {
"branch": null,
"revision": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc",
"version": "5.4.4"
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
"version": "5.5.0"
}
},
{
@ -141,8 +141,8 @@
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
"state": {
"branch": null,
"revision": "a72df4849408da7e5d3c1b586797b7c601c41d1b",
"version": "5.12.1"
"revision": "0fff0d7505b5306348263ea64fcc561253bbeb21",
"version": "5.12.2"
}
},
{

View File

@ -157,11 +157,6 @@ extension SceneCoordinator {
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
case mastodonWebView(viewModel:WebViewModel)
#if ASDK
// ASDK
case asyncHome
#endif
// search
case searchDetail(viewModel: SearchDetailViewModel)
@ -260,7 +255,7 @@ extension SceneCoordinator {
DispatchQueue.main.async {
self.present(
scene: .welcome,
from: nil,
from: self.sceneDelegate.window?.rootViewController,
transition: .modal(animated: animated, completion: nil)
)
}
@ -311,7 +306,7 @@ extension SceneCoordinator {
case .modal(let animated, let completion):
let modalNavigationController: UINavigationController = {
if scene.isOnboarding {
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
return OnboardingNavigationController(rootViewController: viewController)
} else {
return UINavigationController(rootViewController: viewController)
}
@ -412,11 +407,6 @@ private extension SceneCoordinator {
let _viewController = WebViewController()
_viewController.viewModel = viewModel
viewController = _viewController
#if ASDK
case .asyncHome:
let _viewController = AsyncHomeTimelineViewController()
viewController = _viewController
#endif
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController()
_viewController.viewModel = viewModel

View File

@ -0,0 +1,145 @@
//
// PickServerCategoriesCell.swift
// Mastodon
//
// Created by BradGao on 2021/2/23.
//
//import os.log
//import UIKit
//import MastodonSDK
//
//protocol PickServerCategoriesCellDelegate: AnyObject {
// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
//}
//
//final class PickServerCategoriesCell: UITableViewCell {
//
// weak var delegate: PickServerCategoriesCellDelegate?
//
// var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
//
// let metricView = UIView()
//
// let collectionView: UICollectionView = {
// let flowLayout = UICollectionViewFlowLayout()
// flowLayout.scrollDirection = .horizontal
// let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
// view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
// view.backgroundColor = .clear
// view.showsHorizontalScrollIndicator = false
// view.showsVerticalScrollIndicator = false
// view.layer.masksToBounds = false
// view.translatesAutoresizingMaskIntoConstraints = false
// return view
// }()
//
// override func prepareForReuse() {
// super.prepareForReuse()
//
// delegate = nil
// }
//
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
// super.init(style: style, reuseIdentifier: reuseIdentifier)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//}
//
//extension PickServerCategoriesCell {
//
// private func _init() {
// selectionStyle = .none
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
// configureMargin()
//
// metricView.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(metricView)
// NSLayoutConstraint.activate([
// metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
// metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
// metricView.topAnchor.constraint(equalTo: contentView.topAnchor),
// metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
// ])
//
// contentView.addSubview(collectionView)
// NSLayoutConstraint.activate([
// collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
// collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
// collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
// contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20),
// collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
// ])
//
// collectionView.delegate = self
// }
//
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection)
//
// configureMargin()
// }
//
// override func layoutSubviews() {
// super.layoutSubviews()
//
// collectionView.collectionViewLayout.invalidateLayout()
// }
//
//}
//
//extension PickServerCategoriesCell {
// private func configureMargin() {
// switch traitCollection.horizontalSizeClass {
// case .regular:
// let margin = MastodonPickServerViewController.viewEdgeMargin
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
// default:
// contentView.layoutMargins = .zero
// }
// }
//}
//
//// MARK: - UICollectionViewDelegateFlowLayout
//extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
//
// 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)
// delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
// layoutIfNeeded()
// return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
// return 16
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
// return CGSize(width: 60, height: 80)
// }
//
//}
//
//extension PickServerCategoriesCell {
//
// override func accessibilityElementCount() -> Int {
// guard let diffableDataSource = diffableDataSource else { return 0 }
// return diffableDataSource.snapshot().itemIdentifiers.count
// }
//
// override func accessibilityElement(at index: Int) -> Any? {
// guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
// return item
// }
//
//}

View File

@ -0,0 +1,171 @@
//
// PickServerSearchCell.swift
// Mastodon
//
// Created by BradGao on 2021/2/24.
//
import UIKit
//protocol PickServerSearchCellDelegate: AnyObject {
// func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?)
//}
//
//class PickServerSearchCell: UITableViewCell {
//
// weak var delegate: PickServerSearchCellDelegate?
//
// private var bgView: UIView = {
// let view = UIView()
// view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
// view.translatesAutoresizingMaskIntoConstraints = false
// view.layer.maskedCorners = [
// .layerMinXMinYCorner,
// .layerMaxXMinYCorner
// ]
// view.layer.cornerCurve = .continuous
// view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
// return view
// }()
//
// private var textFieldBgView: UIView = {
// let view = UIView()
// view.backgroundColor = Asset.Colors.TextField.background.color
// view.translatesAutoresizingMaskIntoConstraints = false
// view.layer.masksToBounds = true
// view.layer.cornerRadius = 6
// view.layer.cornerCurve = .continuous
// return view
// }()
//
// let searchTextField: UITextField = {
// let textField = UITextField()
// textField.translatesAutoresizingMaskIntoConstraints = false
// textField.leftView = {
// let imageView = UIImageView(
// image: UIImage(
// systemName: "magnifyingglass",
// withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular)
// )
// )
// imageView.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)
//
// let containerView = UIView()
// imageView.translatesAutoresizingMaskIntoConstraints = false
// containerView.addSubview(imageView)
// NSLayoutConstraint.activate([
// imageView.topAnchor.constraint(equalTo: containerView.topAnchor),
// imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
// imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// ])
//
// let paddingView = UIView()
// paddingView.translatesAutoresizingMaskIntoConstraints = false
// containerView.addSubview(paddingView)
// NSLayoutConstraint.activate([
// paddingView.topAnchor.constraint(equalTo: containerView.topAnchor),
// paddingView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor),
// paddingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
// paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// paddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
// ])
// return containerView
// }()
// textField.leftViewMode = .always
// textField.font = .systemFont(ofSize: 15, weight: .regular)
// textField.tintColor = Asset.Colors.Label.primary.color
// textField.textColor = Asset.Colors.Label.primary.color
// textField.adjustsFontForContentSizeCategory = true
// textField.attributedPlaceholder =
// NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
// attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular),
// .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)])
// textField.clearButtonMode = .whileEditing
// textField.autocapitalizationType = .none
// textField.autocorrectionType = .no
// textField.returnKeyType = .done
// textField.keyboardType = .URL
// return textField
// }()
//
// override func prepareForReuse() {
// super.prepareForReuse()
//
// delegate = nil
// }
//
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
// super.init(style: style, reuseIdentifier: reuseIdentifier)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//}
//
//extension PickServerSearchCell {
// private func _init() {
// selectionStyle = .none
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
// configureMargin()
//
// searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
// searchTextField.delegate = self
//
// contentView.addSubview(bgView)
// contentView.addSubview(textFieldBgView)
// contentView.addSubview(searchTextField)
//
// NSLayoutConstraint.activate([
// bgView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
// bgView.topAnchor.constraint(equalTo: contentView.topAnchor),
// bgView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
// bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
//
// textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14),
// textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12),
// bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14),
// bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13),
//
// searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11),
// searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4),
// textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11),
// textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4),
// ])
// }
//
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection)
//
// configureMargin()
// }
//}
//
//extension PickServerSearchCell {
// private func configureMargin() {
// switch traitCollection.horizontalSizeClass {
// case .regular:
// let margin = MastodonPickServerViewController.viewEdgeMargin
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
// default:
// contentView.layoutMargins = .zero
// }
// }
//}
//
//extension PickServerSearchCell {
// @objc private func textFieldDidChange(_ textField: UITextField) {
// delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
// }
//}
//
//// MARK: - UITextFieldDelegate
//extension PickServerSearchCell: UITextFieldDelegate {
//
// func textFieldShouldReturn(_ textField: UITextField) -> Bool {
// textField.resignFirstResponder()
// return false
// }
//}

View File

@ -1,85 +0,0 @@
//
// ASTableNode.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
#if ASDK
import UIKit
import AsyncDisplayKit
import DifferenceKit
import DiffableDataSources
extension ASTableNode: ReloadableTableView {
public func reload<C>(
using stagedChangeset: StagedChangeset<C>,
deleteSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
insertSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
reloadSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
deleteRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
insertRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
reloadRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
interrupt: ((Changeset<C>) -> Bool)? = nil,
setData: (C) -> Void
) {
if case .none = view.window, let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
for changeset in stagedChangeset {
if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
func updates() {
setData(changeset.data)
if !changeset.sectionDeleted.isEmpty {
deleteSections(IndexSet(changeset.sectionDeleted), with: deleteSectionsAnimation())
}
if !changeset.sectionInserted.isEmpty {
insertSections(IndexSet(changeset.sectionInserted), with: insertSectionsAnimation())
}
if !changeset.sectionUpdated.isEmpty {
reloadSections(IndexSet(changeset.sectionUpdated), with: reloadSectionsAnimation())
}
for (source, target) in changeset.sectionMoved {
moveSection(source, toSection: target)
}
if !changeset.elementDeleted.isEmpty {
deleteRows(at: changeset.elementDeleted.map { IndexPath(row: $0.element, section: $0.section) }, with: deleteRowsAnimation())
}
if !changeset.elementInserted.isEmpty {
insertRows(at: changeset.elementInserted.map { IndexPath(row: $0.element, section: $0.section) }, with: insertRowsAnimation())
}
if !changeset.elementUpdated.isEmpty {
reloadRows(at: changeset.elementUpdated.map { IndexPath(row: $0.element, section: $0.section) }, with: reloadRowsAnimation())
}
for (source, target) in changeset.elementMoved {
moveRow(at: IndexPath(row: source.element, section: source.section), to: IndexPath(row: target.element, section: target.section))
}
}
if isNodeLoaded {
view.beginUpdates()
updates()
view.endUpdates(animated: false, completion: nil)
} else {
updates()
}
}
}
}
#endif

View File

@ -1,115 +0,0 @@
//
// TableNodeDiffableDataSource.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
#if ASDK
import UIKit
import AsyncDisplayKit
import DiffableDataSources
open class TableNodeDiffableDataSource<SectionIdentifierType: Hashable, ItemIdentifierType: Hashable>: NSObject, ASTableDataSource {
/// The type of closure providing the cell.
public typealias CellProvider = (ASTableNode, IndexPath, ItemIdentifierType) -> ASCellNodeBlock?
/// The default animation to updating the views.
public var defaultRowAnimation: UITableView.RowAnimation = .automatic
private weak var tableNode: ASTableNode?
private let cellProvider: CellProvider
private let core = DiffableDataSourceCore<SectionIdentifierType, ItemIdentifierType>()
/// Creates a new data source.
///
/// - Parameters:
/// - tableView: A table view instance to be managed.
/// - cellProvider: A closure to dequeue the cell for rows.
public init(tableNode: ASTableNode, cellProvider: @escaping CellProvider) {
self.tableNode = tableNode
self.cellProvider = cellProvider
super.init()
tableNode.delegate = self
}
/// Applies given snapshot to perform automatic diffing update.
///
/// - Parameters:
/// - snapshot: A snapshot object to be applied to data model.
/// - animatingDifferences: A Boolean value indicating whether to update with
/// diffing animation.
/// - completion: An optional completion block which is called when the complete
/// performing updates.
public func apply(_ snapshot: DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
core.apply(snapshot, view: tableNode, animatingDifferences: animatingDifferences, completion: completion)
}
/// Returns a new snapshot object of current state.
///
/// - Returns: A new snapshot object of current state.
public func snapshot() -> DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> {
return core.snapshot()
}
/// Returns an item identifier for given index path.
///
/// - Parameters:
/// - indexPath: An index path for the item identifier.
///
/// - Returns: An item identifier for given index path.
public func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? {
return core.itemIdentifier(for: indexPath)
}
/// Returns an index path for given item identifier.
///
/// - Parameters:
/// - itemIdentifier: An identifier of item.
///
/// - Returns: An index path for given item identifier.
public func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath? {
return core.indexPath(for: itemIdentifier)
}
/// Returns the number of sections in the data source.
///
/// - Parameters:
/// - tableNode: A table node instance managed by `self`.
///
/// - Returns: The number of sections in the data source.
public func numberOfSections(in tableNode: ASTableNode) -> Int {
return core.numberOfSections()
}
/// Returns the number of items in the specified section.
///
/// - Parameters:
/// - tableNode: A table node instance managed by `self`.
/// - section: An index of section.
///
/// - Returns: The number of items in the specified section.
public func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
return core.numberOfItems(inSection: section)
}
/// Returns a cell for row at specified index path.
///
/// - Parameters:
/// - tableView: A table view instance managed by `self`.
/// - indexPath: An index path for cell.
///
/// - Returns: A cell for row at specified index path.
open func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
let itemIdentifier = core.unsafeItemIdentifier(for: indexPath)
guard let block = cellProvider(tableNode, indexPath, itemIdentifier) else {
fatalError("UITableView dataSource returned a nil cell for row at index path: \(indexPath), tableNode: \(tableNode), itemIdentifier: \(itemIdentifier)")
}
return block
}
}
#endif

View File

@ -15,10 +15,11 @@ enum CategoryPickerItem {
}
extension CategoryPickerItem {
var title: String {
var emoji: String {
switch self {
case .all:
return L10n.Scene.ServerPicker.Button.Category.all
return "💬"
case .category(let category):
switch category.category {
case .academia:
@ -32,7 +33,7 @@ extension CategoryPickerItem {
case .games:
return "🕹"
case .general:
return "💬"
return "🐘"
case .journalism:
return "📰"
case .lgbt:
@ -50,6 +51,41 @@ extension CategoryPickerItem {
}
}
}
var title: String {
switch self {
case .all:
return L10n.Scene.ServerPicker.Button.Category.all
case .category(let category):
switch category.category {
case .academia:
return L10n.Scene.ServerPicker.Button.Category.academia
case .activism:
return L10n.Scene.ServerPicker.Button.Category.activism
case .food:
return L10n.Scene.ServerPicker.Button.Category.food
case .furry:
return L10n.Scene.ServerPicker.Button.Category.furry
case .games:
return L10n.Scene.ServerPicker.Button.Category.games
case .general:
return L10n.Scene.ServerPicker.Button.Category.general
case .journalism:
return L10n.Scene.ServerPicker.Button.Category.journalism
case .lgbt:
return L10n.Scene.ServerPicker.Button.Category.lgbt
case .regional:
return L10n.Scene.ServerPicker.Button.Category.regional
case .art:
return L10n.Scene.ServerPicker.Button.Category.art
case .music:
return L10n.Scene.ServerPicker.Button.Category.music
case .tech:
return L10n.Scene.ServerPicker.Button.Category.tech
case ._other:
return "-" // FIXME:
}
}
}
var accessibilityDescription: String {
switch self {
@ -82,7 +118,7 @@ extension CategoryPickerItem {
case .tech:
return L10n.Scene.ServerPicker.Button.Category.tech
case ._other:
return "" // FIXME:
return "-" // FIXME:
}
}
}

View File

@ -19,27 +19,11 @@ extension CategoryPickerSection {
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = dependency else { return nil }
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
switch item {
case .all:
cell.categoryView.titleLabel.font = .systemFont(ofSize: 17)
case .category:
cell.categoryView.titleLabel.font = .systemFont(ofSize: 28)
}
cell.categoryView.emojiLabel.text = item.emoji
cell.categoryView.titleLabel.text = item.title
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
if cell.isSelected {
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = .white
}
} else {
cell.categoryView.bgView.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
}
}
cell.categoryView.highlightedIndicatorView.alpha = cell.isSelected ? 1 : 0
cell.categoryView.titleLabel.textColor = cell.isSelected ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color
}
.store(in: &cell.observations)

View File

@ -12,8 +12,6 @@ import MastodonSDK
/// Note: update Equatable when change case
enum PickServerItem {
case header
case categoryPicker(items: [CategoryPickerItem])
case search
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
case loader(attribute: LoaderItemAttribute)
}
@ -63,10 +61,6 @@ extension PickServerItem: Equatable {
switch (lhs, rhs) {
case (.header, .header):
return true
case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)):
return itemsLeft == itemsRight
case (.search, .search):
return true
case (.server(let serverLeft, _), .server(let serverRight, _)):
return serverLeft.domain == serverRight.domain
case (.loader(let attributeLeft), loader(let attributeRight)):
@ -82,10 +76,6 @@ extension PickServerItem: Hashable {
switch self {
case .header:
hasher.combine(String(describing: PickServerItem.header.self))
case .categoryPicker(let items):
hasher.combine(items)
case .search:
hasher.combine(String(describing: PickServerItem.search.self))
case .server(let server, _):
hasher.combine(server.domain)
case .loader(let attribute):

View File

@ -12,8 +12,6 @@ import AlamofireImage
enum PickServerSection: Equatable, Hashable {
case header
case category
case search
case servers
}
@ -21,36 +19,16 @@ extension PickServerSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency,
weak pickServerCategoriesCellDelegate,
weak pickServerSearchCellDelegate,
weak pickServerCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return nil }
switch item {
case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
return cell
case .categoryPicker(let items):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
cell.delegate = pickServerCategoriesCellDelegate
cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
for: cell.collectionView,
dependency: dependency
)
var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
return cell
case .search:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
cell.delegate = pickServerSearchCellDelegate
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
return cell
case .server(let server, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
@ -70,19 +48,63 @@ extension PickServerSection {
static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) {
cell.domainLabel.text = server.domain
cell.descriptionLabel.text = {
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
return server.description
}
cell.descriptionLabel.attributedText = {
let content: String = {
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
return server.description
}
return html.text ?? server.description
}()
return html.text ?? server.description
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 1.16
return NSAttributedString(
string: content,
attributes: [
.paragraphStyle: paragraphStyle
]
)
}()
cell.langValueLabel.text = server.language.uppercased()
cell.usersValueLabel.text = parseUsersCount(server.totalUsers)
cell.categoryValueLabel.text = server.category.uppercased()
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
cell.usersValueLabel.attributedText = {
let attributedString = NSMutableAttributedString()
let attachment = NSTextAttachment(image: UIImage(systemName: "person.2.fill")!)
let attachmentAttributedString = NSAttributedString(attachment: attachment)
attributedString.append(attachmentAttributedString)
attributedString.append(NSAttributedString(string: " "))
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 1.12
let valueAttributedString = NSAttributedString(
string: parseUsersCount(server.totalUsers),
attributes: [
.paragraphStyle: paragraphStyle
]
)
attributedString.append(valueAttributedString)
return attributedString
}()
cell.langValueLabel.attributedText = {
let attributedString = NSMutableAttributedString()
let attachment = NSTextAttachment(image: UIImage(systemName: "text.bubble.fill")!)
let attachmentAttributedString = NSAttributedString(attachment: attachment)
attributedString.append(attachmentAttributedString)
attributedString.append(NSAttributedString(string: " "))
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 1.12
let valueAttributedString = NSAttributedString(
string: server.language.uppercased(),
attributes: [
.paragraphStyle: paragraphStyle
]
)
attributedString.append(valueAttributedString)
return attributedString
}()
attribute.isLast
.receive(on: DispatchQueue.main)
.sink { [weak cell] isLast in
@ -101,41 +123,6 @@ extension PickServerSection {
}
}
.store(in: &cell.disposeBag)
cell.expandMode
.receive(on: DispatchQueue.main)
.sink { mode in
switch mode {
case .collapse:
// do nothing
break
case .expand:
let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill)
.af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false)
guard let proxiedThumbnail = server.proxiedThumbnail,
let url = URL(string: proxiedThumbnail) else {
cell.thumbnailImageView.image = placeholderImage
cell.thumbnailActivityIndicator.stopAnimating()
return
}
cell.thumbnailImageView.isHidden = false
cell.thumbnailActivityIndicator.startAnimating()
cell.thumbnailImageView.af.setImage(
withURL: url,
placeholderImage: placeholderImage,
filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3),
imageTransition: .crossDissolve(0.33),
completion: { [weak cell] response in
switch response.result {
case .success, .failure:
cell?.thumbnailActivityIndicator.stopAnimating()
}
}
)
}
}
.store(in: &cell.disposeBag)
}
private static func parseUsersCount(_ usersCount: Int) -> String {

View File

@ -0,0 +1,19 @@
//
// RegisterItem.swift
// Mastodon
//
// Created by MainasuK on 2022-1-5.
//
import Foundation
enum RegisterItem: Hashable {
case header
case avatar
case name
case username
case email
case password
case hint
case reason
}

View File

@ -0,0 +1,12 @@
//
// RegisterSection.swift
// Mastodon
//
// Created by MainasuK on 2022-1-5.
//
import UIKit
enum RegisterSection: Hashable {
case main
}

View File

@ -0,0 +1,21 @@
//
// ServerRuleItem.swift
// Mastodon
//
// Created by MainasuK on 2022-1-5.
//
import Foundation
import MastodonSDK
enum ServerRuleItem: Hashable {
case header(domain: String)
case rule(RuleContext)
}
extension ServerRuleItem {
struct RuleContext: Hashable {
let index: Int
let rule: Mastodon.Entity.Instance.Rule
}
}

View File

@ -0,0 +1,34 @@
//
// ServerRuleSection.swift
// Mastodon
//
// Created by MainasuK on 2022-1-5.
//
import UIKit
enum ServerRuleSection: Hashable {
case header
case rules
}
extension ServerRuleSection {
static func tableViewDiffableDataSource(
tableView: UITableView
) -> UITableViewDiffableDataSource<ServerRuleSection, ServerRuleItem> {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .header(let domain):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
cell.titleLabel.text = L10n.Scene.ServerRules.title
cell.subTitleLabel.text = L10n.Scene.ServerRules.subtitle(domain)
return cell
case .rule(let ruleContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ServerRulesTableViewCell.self), for: indexPath) as! ServerRulesTableViewCell
cell.indexImageView.image = UIImage(systemName: "\(ruleContext.index + 1).circle.fill") ?? UIImage(systemName: "questionmark.circle.fill")
cell.ruleLabel.text = ruleContext.rule.text
return cell
}
}
}
}

View File

@ -18,10 +18,6 @@ import NaturalLanguage
// import LinkPresentation
#if ASDK
import AsyncDisplayKit
#endif
protocol StatusCell: DisposeBagCollectable {
var statusView: StatusView { get }
var isFiltered: Bool { get set }
@ -32,33 +28,6 @@ enum StatusSection: Equatable, Hashable {
}
extension StatusSection {
#if ASDK
static func tableNodeDiffableDataSource(
tableNode: ASTableNode,
managedObjectContext: NSManagedObjectContext
) -> TableNodeDiffableDataSource<StatusSection, Item> {
TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in
switch item {
case .homeTimelineIndex(let objectID, let attribute):
guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
return { ASCellNode() }
}
let status = homeTimelineIndex.status
return { () -> ASCellNode in
let cellNode = StatusNode(status: status)
return cellNode
}
case .homeMiddleLoader:
return { TimelineMiddleLoaderNode() }
case .bottomLoader:
return { TimelineBottomLoaderNode() }
default:
return { ASCellNode() }
}
}
}
#endif
static let logger = Logger(subsystem: "StatusSection", category: "logic")

View File

@ -47,6 +47,7 @@ internal enum Asset {
}
internal enum Label {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let primaryReverse = ColorAsset(name: "Colors/Label/primary.reverse")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
}
@ -89,6 +90,16 @@ internal enum Asset {
internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive")
}
internal enum Scene {
internal enum Onboarding {
internal static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder")
internal static let navigationBackButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background")
internal static let navigationBackButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background.highlighted")
internal static let navigationNextButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background")
internal static let navigationNextButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background.highlighted")
internal static let onboardingBackground = ColorAsset(name: "Scene/Onboarding/onboarding.background")
internal static let searchBarBackground = ColorAsset(name: "Scene/Onboarding/search.bar.background")
internal static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background")
}
internal enum Profile {
internal enum Banner {
internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray")
@ -102,8 +113,10 @@ internal enum Asset {
internal enum Welcome {
internal enum Illustration {
internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan")
internal static let cloudBaseExtend = ImageAsset(name: "Scene/Welcome/illustration/cloud.base.extend")
internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base")
internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail")
internal static let elephantThreeOnGrassExtend = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.extend")
internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass")
internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three")
internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two")
@ -112,6 +125,7 @@ internal enum Asset {
internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large")
internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo")
internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
internal static let signInButtonBackground = ColorAsset(name: "Scene/Welcome/sign.in.button.background")
}
}
internal enum Settings {

View File

@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<string>1.3.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>88</string>
<string>90</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>

View File

@ -1,17 +0,0 @@
//
// StatusProvider+StatusNodeDelegate.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-20.
//
#if ASDK
import Foundation
// MARK: - StatusViewDelegate
extension StatusNodeDelegate where Self: StatusProvider {
}
#endif

View File

@ -10,10 +10,6 @@ import Combine
import CoreData
import CoreDataStack
#if ASDK
import AsyncDisplayKit
#endif
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
// async
func status() -> Future<Status?, Never>
@ -31,20 +27,8 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
func items(indexPaths: [IndexPath]) -> [Item]
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem]
#if ASDK
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status?
#endif
}
#if ASDK
extension StatusProvider {
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? {
fatalError("Needs implement this")
}
}
#endif
enum StatusObjectItem {
case status(objectID: NSManagedObjectID)
case homeTimelineIndex(objectID: NSManagedObjectID)

View File

@ -14,10 +14,6 @@ import MastodonSDK
import Meta
import MetaTextKit
#if ASDK
import AsyncDisplayKit
#endif
enum StatusProviderFacade { }
extension StatusProviderFacade {
@ -154,13 +150,6 @@ extension StatusProviderFacade {
}
}
#if ASDK
private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, node: ASCellNode, mention: String) {
guard let status = provider.status(node: node, indexPath: nil) else { return }
coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention, href: nil)
}
#endif
private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String, href: String?) {
provider.status(for: cell, indexPath: nil)
.sink { [weak provider] status in

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
"blue" : "0.216",
"green" : "0.173",
"red" : "0.157"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
"blue" : "0xEE",
"green" : "0xEE",
"red" : "0xEE"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.933",
"green" : "0.933",
"red" : "0.933"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.216",
"green" : "0.173",
"red" : "0.157"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -22,10 +22,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "0xF5",
"green" : "0xEB",
"red" : "0xEB"
"alpha" : "1.000",
"blue" : "0xAD",
"green" : "0x9D",
"red" : "0x97"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Frame 82.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Frame 82@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Frame 82@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.200",
"blue" : "0x80",
"green" : "0x78",
"red" : "0x78"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE5",
"green" : "0xE5",
"red" : "0xE5"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.400",
"blue" : "0x80",
"green" : "0x78",
"red" : "0x78"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x37",
"green" : "0x2C",
"red" : "0x28"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xEE",
"green" : "0xEE",
"red" : "0xEE"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1B",
"green" : "0x15",
"red" : "0x13"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xBA",
"green" : "0xBA",
"red" : "0xBA"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF7",
"green" : "0xF2",
"red" : "0xF2"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x21",
"green" : "0x1B",
"red" : "0x19"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.200",
"blue" : "0x80",
"green" : "0x78",
"red" : "0x78"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.240",
"blue" : "0x80",
"green" : "0x76",
"red" : "0x76"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x37",
"green" : "0x2C",
"red" : "0x28"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "cloud.base.extend.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "cloud.base.extend@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "cloud.base.extend@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "elephant.three.on.grass.extend.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "elephant.three.on.grass.extend@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "elephant.three.on.grass.extend@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x81",
"green" : "0xAC",
"red" : "0x58"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.898",
"red" : "0.867"
"blue" : "0xEB",
"green" : "0xE4",
"red" : "0xDD"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.910",
"green" : "0.882",
"red" : "0.851"
"blue" : "0xE8",
"green" : "0xE0",
"red" : "0xD9"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.910",
"green" : "0.882",
"red" : "0.851"
"blue" : "0xE8",
"green" : "0xE0",
"red" : "0xD9"
}
},
"idiom" : "universal"

View File

@ -1,384 +0,0 @@
//
// AsyncHomeTimelineViewController+DebugAction.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK && DEBUG
import os.log
import UIKit
import CoreData
import CoreDataStack
import FLEX
extension AsyncHomeTimelineViewController {
var debugMenu: UIMenu {
let menu = UIMenu(
title: "Debug Tools",
image: nil,
identifier: nil,
options: .displayInline,
children: [
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.showFLEXAction(action)
}),
moveMenu,
dropMenu,
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showWelcomeAction(action)
},
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
guard let self = self else { return }
if self.emptyView.superview != nil {
self.emptyView.removeFromSuperview()
} else {
self.showEmptyView()
}
},
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
},
UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showProfileAction(action)
},
UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showThreadAction(action)
},
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showSettings(action)
},
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
}
]
)
return menu
}
var moveMenu: UIMenu {
return UIMenu(
title: "Move to…",
image: UIImage(systemName: "arrow.forward.circle"),
identifier: nil,
options: [],
children: [
UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToTopGapAction(action)
}),
UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstRepliedStatus(action)
}),
UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstReblogStatus(action)
}),
UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstPollStatus(action)
}),
UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstAudioStatus(action)
}),
UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstVideoStatus(action)
}),
UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstGIFStatus(action)
}),
]
)
}
var dropMenu: UIMenu {
return UIMenu(
title: "Drop…",
image: UIImage(systemName: "minus.circle"),
identifier: nil,
options: [],
children: [10, 50, 100, 150, 200, 250, 300].map { count in
UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.dropRecentStatusAction(action, count: count)
})
}
)
}
}
extension AsyncHomeTimelineViewController {
@objc private func showFLEXAction(_ sender: UIAction) {
FLEXManager.shared.showExplorer()
}
@objc private func moveToTopGapAction(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeMiddleLoader: return true
default: return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
}
}
@objc private func moveToFirstReblogStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
return homeTimelineIndex.status.reblog != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found reblog status")
}
}
@objc private func moveToFirstPollStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return post.poll != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found poll status")
}
}
@objc private func moveToFirstRepliedStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
guard homeTimelineIndex.status.inReplyToID != nil else {
return false
}
return true
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found replied status")
}
}
@objc private func moveToFirstAudioStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found audio status")
}
}
@objc private func moveToFirstVideoStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found video status")
}
}
@objc private func moveToFirstGIFStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found GIF status")
}
}
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in
switch item {
case .homeTimelineIndex(let objectID, _): return objectID
default: return nil
}
}
var droppingStatusObjectIDs: [NSManagedObjectID] = []
context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingObjectIDs {
guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue }
droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID)
self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex)
}
}
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingStatusObjectIDs {
guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue }
self.context.apiService.backgroundManagedObjectContext.delete(post)
}
}
.sink { _ in
// do nothing
}
.store(in: &self.disposeBag)
case .failure(let error):
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
@objc private func showWelcomeAction(_ sender: UIAction) {
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}
@objc private func showProfileAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
@objc private func showThreadAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "")
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
@objc private func showSettings(_ sender: UIAction) {
guard let currentSetting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting)
coordinator.present(
scene: .settings(viewModel: settingsViewModel),
from: self,
transition: .modal(animated: true, completion: nil)
)
}
@objc func signOutAction(_ sender: UIAction) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
context.authenticationService.signOutMastodonUser(
domain: activeMastodonAuthenticationBox.domain,
userID: activeMastodonAuthenticationBox.userID
)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .success(let isSignOut):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail")
guard isSignOut else { return }
self.coordinator.setup()
self.coordinator.setupOnboardingIfNeeds(animated: true)
}
}
.store(in: &disposeBag)
}
}
#endif

View File

@ -1,123 +0,0 @@
//
// AsyncHomeTimelineViewController+Provider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import AsyncDisplayKit
// MARK: - StatusProvider
extension AsyncHomeTimelineViewController: StatusProvider {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
switch item {
case .homeTimelineIndex(let objectID, _):
let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
promise(.success(timelineIndex?.status))
}
default:
promise(.success(nil))
}
}
}
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return nil
}
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
return item
}
func items(indexPaths: [IndexPath]) -> [Item] {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return []
}
var items: [Item] = []
for indexPath in indexPaths {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
items.append(item)
}
return items
}
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? node.flatMap({ self.node.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
switch item {
case .homeTimelineIndex(let objectID, _):
guard let homeTimelineIndex = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
assertionFailure()
return nil
}
return homeTimelineIndex.status
default:
return nil
}
}
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
return items
}
}
extension AsyncHomeTimelineViewController: UserProvider {}
#endif

View File

@ -1,573 +0,0 @@
//
// AsyncHomeTimelineViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK
import os.log
import UIKit
import AVKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import AlamofireImage
import AsyncDisplayKit
final class AsyncHomeTimelineViewController: ASDKViewController<ASTableNode>, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = AsyncHomeTimelineViewModel(context: context)
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var emptyView: UIStackView = {
let emptyView = UIStackView()
emptyView.axis = .vertical
emptyView.distribution = .fill
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
emptyView.isLayoutMarginsRelativeArrangement = true
return emptyView
}()
let titleView = HomeTimelineNavigationBarTitleView()
let settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.brandBlue.color
barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.brandBlue.color
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
var tableView: UITableView { node.view }
let publishProgressView: UIProgressView = {
let progressView = UIProgressView(progressViewStyle: .bar)
progressView.alpha = 0
return progressView
}()
let refreshControl = UIRefreshControl()
override init() {
super.init(node: ASTableNode())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension AsyncHomeTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
node.allowsSelection = true
title = L10n.Scene.HomeTimeline.title
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
navigationItem.leftBarButtonItem = settingBarButtonItem
navigationItem.titleView = titleView
titleView.delegate = self
viewModel.homeTimelineNavigationBarTitleViewModel.state
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
self.titleView.configure(state: state)
}
.store(in: &disposeBag)
#if DEBUG
// long press to trigger debug menu
settingBarButtonItem.menu = debugMenu
#else
settingBarButtonItem.target = self
settingBarButtonItem.action = #selector(AsyncHomeTimelineViewController.settingBarButtonItemPressed(_:))
#endif
navigationItem.rightBarButtonItem = composeBarButtonItem
composeBarButtonItem.target = self
composeBarButtonItem.action = #selector(AsyncHomeTimelineViewController.composeBarButtonItemPressed(_:))
node.view.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(AsyncHomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
//
// tableView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(tableView)
// NSLayoutConstraint.activate([
// tableView.topAnchor.constraint(equalTo: view.topAnchor),
// tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// ])
//
// publishProgressView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(publishProgressView)
// NSLayoutConstraint.activate([
// publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
// publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// ])
//
// viewModel.tableView = tableView
viewModel.tableNode = node
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
node.delegate = self
viewModel.setupDiffableDataSource(
tableNode: node,
dependency: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
// tableView.delegate = self
// tableView.prefetchDataSource = self
// bind refresh control
viewModel.isFetchingLatestTimeline
.receive(on: DispatchQueue.main)
.sink { [weak self] isFetching in
guard let self = self else { return }
if !isFetching {
UIView.animate(withDuration: 0.5) { [weak self] in
guard let self = self else { return }
self.refreshControl.endRefreshing()
} completion: { _ in }
}
}
.store(in: &disposeBag)
// viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
// .receive(on: DispatchQueue.main)
// .sink { [weak self] progress in
// guard let self = self else { return }
// guard progress > 0 else {
// let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
// dismissAnimator.addAnimations {
// self.publishProgressView.alpha = 0
// }
// dismissAnimator.addCompletion { _ in
// self.publishProgressView.setProgress(0, animated: false)
// }
// dismissAnimator.startAnimation()
// return
// }
// if self.publishProgressView.alpha == 0 {
// let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
// progressAnimator.addAnimations {
// self.publishProgressView.alpha = 1
// }
// progressAnimator.startAnimation()
// }
//
// self.publishProgressView.setProgress(progress, animated: true)
// }
// .store(in: &disposeBag)
//
// viewModel.timelineIsEmpty
// .receive(on: DispatchQueue.main)
// .sink { [weak self] isEmpty in
// if isEmpty {
// self?.showEmptyView()
// } else {
// self?.emptyView.removeFromSuperview()
// }
// }
// .store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// aspectViewWillAppear(animated)
//
// // needs trigger manually after onboarding dismiss
// setNeedsStatusBarAppearanceUpdate()
//
// if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
// viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
// }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// viewModel.viewDidAppear.send()
//
// DispatchQueue.main.async { [weak self] in
// guard let self = self else { return }
// if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
// self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
// }
// }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// aspectViewDidDisappear(animated)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// coordinator.animate { _ in
// // do nothing
// } completion: { _ in
// // fix AutoLayout cell height not update after rotate issue
// self.viewModel.cellFrameCache.removeAllObjects()
// self.tableView.reloadData()
// }
}
}
extension AsyncHomeTimelineViewController {
func showEmptyView() {
if emptyView.superview != nil {
return
}
view.addSubview(emptyView)
emptyView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
])
if emptyView.arrangedSubviews.count > 0 {
return
}
let findPeopleButton: PrimaryActionButton = {
let button = PrimaryActionButton()
button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal)
button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside)
return button
}()
NSLayoutConstraint.activate([
findPeopleButton.heightAnchor.constraint(equalToConstant: 46)
])
let manuallySearchButton: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
return button
}()
emptyView.addArrangedSubview(findPeopleButton)
emptyView.setCustomSpacing(17, after: findPeopleButton)
emptyView.addArrangedSubview(manuallySearchButton)
}
}
extension AsyncHomeTimelineViewController {
@objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
let viewModel = SuggestionAccountViewModel(context: context)
viewModel.delegate = self.viewModel
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
coordinator.switchToTabBar(tab: .search)
}
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let setting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let composeViewModel = ComposeViewModel(context: context, composeKind: .post)
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
sender.endRefreshing()
return
}
}
}
// MARK: - StatusTableViewControllerAspect
//extension AsyncHomeTimelineViewController: StatusTableViewControllerAspect { }
//extension AsyncHomeTimelineViewController: TableViewCellHeightCacheableContainer {
// var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache }
//}
// MARK: - UIScrollViewDelegate
extension AsyncHomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
//aspectScrollViewDidScroll(scrollView)
viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
}
}
//extension AsyncHomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading
// var loadMoreConfigurableTableView: UITableView { return tableView }
// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine }
//}
// MARK: - UITableViewDelegate
//extension AsyncHomeTimelineViewController: UITableViewDelegate {
//
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didSelectRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
// }
//
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
// }
//
//}
// MARK: - UITableViewDataSourcePrefetching
//extension AsyncHomeTimelineViewController: UITableViewDataSourcePrefetching {
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// aspectTableView(tableView, prefetchRowsAt: indexPaths)
// }
//}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
extension AsyncHomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
func navigationBar() -> UINavigationBar? {
return navigationController?.navigationBar
}
}
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
extension AsyncHomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
return
}
viewModel.loadMiddleSateMachineList
.receive(on: DispatchQueue.main)
.sink { [weak self] ids in
guard let _ = self else { return }
if let stateMachine = ids[upperTimelineIndexObjectID] {
guard let state = stateMachine.currentState else {
assertionFailure()
return
}
// make success state same as loading due to snapshot updating delay
let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success
if isLoading {
cell.startAnimating()
} else {
cell.stopAnimating()
}
} else {
cell.stopAnimating()
}
}
.store(in: &cell.disposeBag)
var dict = viewModel.loadMiddleSateMachineList.value
if let _ = dict[upperTimelineIndexObjectID] {
// do nothing
} else {
let stateMachine = GKStateMachine(states: [
AsyncHomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
AsyncHomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
AsyncHomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
AsyncHomeTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
])
stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Initial.self)
dict[upperTimelineIndexObjectID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict
}
}
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .homeMiddleLoader(let upper):
guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else {
assertionFailure()
return
}
stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Loading.self)
default:
assertionFailure()
}
}
}
// MARK: - ScrollViewContainer
extension AsyncHomeTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { return tableView }
func scrollToTop(animated: Bool) {
if scrollView.contentOffset.y < scrollView.frame.height,
viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self),
(scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0,
!refreshControl.isRefreshing {
scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.refreshControl.beginRefreshing()
self.refreshControl.sendActions(for: .valueChanged)
}
} else {
let indexPath = IndexPath(row: 0, section: 0)
guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return }
node.scrollToRow(at: indexPath, at: .top, animated: true)
}
}
}
// MARK: - AVPlayerViewControllerDelegate
extension AsyncHomeTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - StatusTableViewCellDelegate
extension AsyncHomeTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
extension AsyncHomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) {
scrollToTop(animated: true)
}
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
switch titleView.state {
case .newPostButton:
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let indexPath = IndexPath(row: 0, section: 0)
guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
node.scrollToRow(at: indexPath, at: .top, animated: true)
case .offlineButton:
// TODO: retry
break
case .publishedButton:
break
default:
break
}
}
}
extension AsyncHomeTimelineViewController {
override var keyCommands: [UIKeyCommand]? {
return navigationKeyCommands + statusNavigationKeyCommands
}
}
// MARK: - StatusTableViewControllerNavigateable
extension AsyncHomeTimelineViewController: StatusTableViewControllerNavigateable {
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
navigateKeyCommandHandler(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
statusKeyCommandHandler(sender)
}
}
// MARK: - ASTableDelegate
extension AsyncHomeTimelineViewController: ASTableDelegate {
func shouldBatchFetch(for tableNode: ASTableNode) -> Bool {
switch viewModel.loadLatestStateMachine.currentState {
case is HomeTimelineViewModel.LoadOldestState.NoMore:
return false
default:
return true
}
}
func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) {
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
context.completeBatchFetching(true)
}
func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) {
if let statusNode = node as? StatusNode {
statusNode.delegate = self
}
}
}
// MARK: - StatusNodeDelegate
extension AsyncHomeTimelineViewController: StatusNodeDelegate { }
#endif

View File

@ -1,159 +0,0 @@
//
// AsyncHomeTimelineViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK
import os.log
import UIKit
import CoreData
import CoreDataStack
import AsyncDisplayKit
import DifferenceKit
import DiffableDataSources
extension AsyncHomeTimelineViewModel {
func setupDiffableDataSource(
tableNode: ASTableNode,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) {
tableNode.automaticallyAdjustsContentOffset = true
diffableDataSource = StatusSection.tableNodeDiffableDataSource(
tableNode: tableNode,
managedObjectContext: fetchedResultsController.managedObjectContext
)
var snapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension AsyncHomeTimelineViewModel: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let diffableDataSource = self.diffableDataSource else { return }
let oldSnapshot = diffableDataSource.snapshot()
let predicate = fetchedResultsController.fetchRequest.predicate
let parentManagedObjectContext = fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
managedObjectContext.perform {
var shouldAddBottomLoader = false
let timelineIndexes: [HomeTimelineIndex] = {
let request = HomeTimelineIndex.sortedFetchRequest
request.returnsObjectsAsFaults = false
request.predicate = predicate
do {
return try managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}()
// that's will be the most fastest fetch because of upstream just update and no modify needs consider
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .homeTimelineIndex(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
var newTimelineItems: [Item] = []
for (i, timelineIndex) in timelineIndexes.enumerated() {
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute()
attribute.isSeparatorLineHidden = false
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))
let isLast = i == timelineIndexes.count - 1
switch (isLast, timelineIndex.hasMore) {
case (false, true):
newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID))
attribute.isSeparatorLineHidden = true
case (true, true):
shouldAddBottomLoader = true
default:
break
}
} // end for
var newSnapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
newSnapshot.appendSections([.main])
newSnapshot.appendItems(newTimelineItems, toSection: .main)
let endSnapshot = CACurrentMediaTime()
if shouldAddBottomLoader, !(self.loadLatestStateMachine.currentState is LoadOldestState.NoMore) {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isFetchingLatestTimeline.value = false
}
let end = CACurrentMediaTime()
os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot)
}
} // end perform
}
private struct Difference<T> {
let item: T
let sourceIndexPath: IndexPath
let targetIndexPath: IndexPath
let offset: CGFloat
}
private func calculateReloadSnapshotDifference<T: Hashable>(
navigationBar: UINavigationBar,
tableView: UITableView,
oldSnapshot: DiffableDataSourceSnapshot<StatusSection, T>,
newSnapshot: DiffableDataSourceSnapshot<StatusSection, T>
) -> Difference<T>? {
guard oldSnapshot.numberOfItems != 0 else { return nil }
// old snapshot not empty. set source index path to first item if not match
let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0)
guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil }
let targetIndexPath = IndexPath(row: itemIndex, section: 0)
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
return Difference(
item: timelineItem,
sourceIndexPath: sourceIndexPath,
targetIndexPath: targetIndexPath,
offset: offset
)
}
}
#endif

View File

@ -1,134 +0,0 @@
//
// AsyncHomeTimelineViewModel+LoadLatestState.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
//
#if ASDK
import os.log
import func QuartzCore.CACurrentMediaTime
import Foundation
import CoreData
import CoreDataStack
import GameplayKit
extension AsyncHomeTimelineViewModel {
class LoadLatestState: GKState {
weak var viewModel: AsyncHomeTimelineViewModel?
init(viewModel: AsyncHomeTimelineViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.loadLatestStateMachinePublisher.send(self)
}
}
}
extension AsyncHomeTimelineViewModel.LoadLatestState {
class Initial: AsyncHomeTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class Loading: AsyncHomeTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
// sign out when loading will enter here
stateMachine.enter(Fail.self)
return
}
let predicate = viewModel.fetchedResultsController.fetchRequest.predicate
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
managedObjectContext.perform {
let start = CACurrentMediaTime()
let latestStatusIDs: [Status.ID]
let request = HomeTimelineIndex.sortedFetchRequest
request.returnsObjectsAsFaults = false
request.predicate = predicate
do {
let timelineIndexes = try managedObjectContext.fetch(request)
let endFetch = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start)
latestStatusIDs = timelineIndexes
.prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue
.compactMap { timelineIndex in
timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID
}
} catch {
stateMachine.enter(Fail.self)
return
}
let end = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
// TODO: only set large count when using Wi-Fi
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
// TODO: handle error
viewModel.isFetchingLatestTimeline.value = false
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
stateMachine.enter(Idle.self)
} receiveValue: { response in
// stop refresher if no new statuses
let statuses = response.value
let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count)
if newStatuses.isEmpty {
viewModel.isFetchingLatestTimeline.value = false
} else {
if !latestStatusIDs.isEmpty {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
}
.store(in: &viewModel.disposeBag)
}
}
}
class Fail: AsyncHomeTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: AsyncHomeTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
}
#endif

View File

@ -1,112 +0,0 @@
//
// AsyncHomeTimelineViewModel+LoadMiddleState.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK
import os.log
import Foundation
import GameplayKit
import CoreData
import CoreDataStack
extension AsyncHomeTimelineViewModel {
class LoadMiddleState: GKState {
weak var viewModel: AsyncHomeTimelineViewModel?
let upperTimelineIndexObjectID: NSManagedObjectID
init(viewModel: AsyncHomeTimelineViewModel, upperTimelineIndexObjectID: NSManagedObjectID) {
self.viewModel = viewModel
self.upperTimelineIndexObjectID = upperTimelineIndexObjectID
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
var dict = viewModel.loadMiddleSateMachineList.value
dict[upperTimelineIndexObjectID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
}
}
}
extension AsyncHomeTimelineViewModel.LoadMiddleState {
class Initial: AsyncHomeTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class Loading: AsyncHomeTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return stateClass == Success.self || stateClass == Fail.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
guard let timelineIndex = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperTimelineIndexObjectID }) else {
stateMachine.enter(Fail.self)
return
}
let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in
timelineIndex.status.id
}
// TODO: only set large count when using Wi-Fi
let maxID = timelineIndex.status.id
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
// TODO: handle error
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
let statuses = response.value
let newStatuses = statuses.filter { !statusIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count)
if newStatuses.isEmpty {
stateMachine.enter(Fail.self)
} else {
stateMachine.enter(Success.self)
}
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: AsyncHomeTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return stateClass == Loading.self
}
}
class Success: AsyncHomeTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return false
}
}
}
#endif

View File

@ -1,117 +0,0 @@
//
// AsyncHomeTimelineViewModel+LoadOldestState.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
#if ASDK
import os.log
import Foundation
import GameplayKit
extension AsyncHomeTimelineViewModel {
class LoadOldestState: GKState {
weak var viewModel: AsyncHomeTimelineViewModel?
init(viewModel: AsyncHomeTimelineViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.loadOldestStateMachinePublisher.send(self)
}
}
}
extension AsyncHomeTimelineViewModel.LoadOldestState {
class Initial: AsyncHomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
return stateClass == Loading.self
}
}
class Loading: AsyncHomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
stateMachine.enter(Fail.self)
return
}
guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else {
stateMachine.enter(Idle.self)
return
}
// TODO: only set large count when using Wi-Fi
let maxID = last.status.id
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { response in
let statuses = response.value
// enter no more state when no new statuses
if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)
}
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: AsyncHomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: AsyncHomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class NoMore: AsyncHomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// reset state if needs
return stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
guard let viewModel = viewModel else { return }
guard let diffableDataSource = viewModel.diffableDataSource else {
assertionFailure()
return
}
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}
#endif

View File

@ -1,151 +0,0 @@
//
// AsyncHomeTimelineViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
//
#if ASDK
import os.log
import func AVFoundation.AVMakeRect
import UIKit
import AVKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import AlamofireImage
import DateToolsSwift
import AsyncDisplayKit
final class AsyncHomeTimelineViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
// input
let context: AppContext
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
let viewDidAppear = PassthroughSubject<Void, Never>()
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
weak var tableNode: ASTableNode?
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
//weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
// output
var diffableDataSource: TableNodeDiffableDataSource<StatusSection, Item>?
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadLatestState.Initial(viewModel: self),
LoadLatestState.Loading(viewModel: self),
LoadLatestState.Fail(viewModel: self),
LoadLatestState.Idle(viewModel: self),
])
stateMachine.enter(LoadLatestState.Initial.self)
return stateMachine
}()
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
// bottom loader
private(set) lazy var loadOldestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadOldestState.Initial(viewModel: self),
LoadOldestState.Loading(viewModel: self),
LoadOldestState.Fail(viewModel: self),
LoadOldestState.Idle(viewModel: self),
LoadOldestState.NoMore(viewModel: self),
])
stateMachine.enter(LoadOldestState.Initial.self)
return stateMachine
}()
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
// middle loader
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
// var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
var cellFrameCache = NSCache<NSNumber, NSValue>()
init(context: AppContext) {
self.context = context
self.fetchedResultsController = {
let fetchRequest = HomeTimelineIndex.sortedFetchRequest
fetchRequest.fetchBatchSize = 20
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)]
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
super.init()
fetchedResultsController.delegate = self
timelinePredicate
.receive(on: DispatchQueue.main)
.compactMap { $0 }
.first() // set once
.sink { [weak self] predicate in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in
guard let self = self else { return }
guard let mastodonAuthentication = activeMastodonAuthentication else { return }
let activeMastodonUserID = mastodonAuthentication.userID
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
HomeTimelineIndex.predicate(userID: activeMastodonUserID),
HomeTimelineIndex.notDeleted()
])
self.timelinePredicate.value = predicate
}
.store(in: &disposeBag)
homeTimelineNeedRefresh
.sink { [weak self] _ in
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
}
.store(in: &disposeBag)
homeTimelineNavigationBarTitleViewModel.isPublished
.sink { [weak self] isPublished in
guard let self = self else { return }
self.homeTimelineNeedRefresh.send()
}
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension AsyncHomeTimelineViewModel: SuggestionAccountViewModelDelegate { }
#endif

View File

@ -46,21 +46,11 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
imageView.contentMode = .scaleAspectFit
return imageView
}()
let openEmailButton: UIButton = {
let button = PrimaryActionButton()
button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
button.addTarget(self, action: #selector(openEmailButtonPressed(_:)), for: UIControl.Event.touchUpInside)
return button
}()
let dontReceiveButton: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15))
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal)
button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside)
return button
let navigationActionView: NavigationActionView = {
let navigationActionView = NavigationActionView()
navigationActionView.backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
return navigationActionView
}()
deinit {
@ -73,6 +63,8 @@ extension MastodonConfirmEmailViewController {
override func viewDidLoad() {
navigationItem.leftBarButtonItem = UIBarButtonItem()
setupOnboardingAppearance()
configureTitleLabel()
configureMargin()
@ -83,13 +75,12 @@ extension MastodonConfirmEmailViewController {
stackView.spacing = 10
stackView.layoutMargins = UIEdgeInsets(top: 10, left: 0, bottom: 23, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.addArrangedSubview(self.largeTitleLabel)
stackView.addArrangedSubview(self.subtitleLabel)
stackView.addArrangedSubview(self.emailImageView)
stackView.addArrangedSubview(largeTitleLabel)
stackView.addArrangedSubview(subtitleLabel)
stackView.addArrangedSubview(emailImageView)
emailImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
emailImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
stackView.addArrangedSubview(self.openEmailButton)
stackView.addArrangedSubview(self.dontReceiveButton)
stackView.addArrangedSubview(navigationActionView)
view.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
@ -99,10 +90,7 @@ extension MastodonConfirmEmailViewController {
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor),
])
NSLayoutConstraint.activate([
self.openEmailButton.heightAnchor.constraint(equalToConstant: 46),
])
self.viewModel.timestampUpdatePublisher
.sink { [weak self] _ in
guard let self = self else { return }
@ -140,6 +128,13 @@ extension MastodonConfirmEmailViewController {
.store(in: &self.disposeBag)
}
.store(in: &self.disposeBag)
navigationActionView.backButton.setTitle("Resend", for: .normal) // TODO: i18n
navigationActionView.backButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.resendButtonPressed(_:)), for: .touchUpInside)
navigationActionView.nextButton.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
navigationActionView.nextButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.openEmailButtonPressed(_:)), for: .touchUpInside)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -190,7 +185,7 @@ extension MastodonConfirmEmailViewController {
self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
@objc private func dontReceiveButtonPressed(_ sender: UIButton) {
@objc private func resendButtonPressed(_ sender: UIButton) {
let alertController = UIAlertController(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.title, message: L10n.Scene.ConfirmEmail.DontReceiveEmail.description, preferredStyle: .alert)
let resendAction = UIAlertAction(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.resendEmail, style: .default) { _ in
let url = Mastodon.API.resendEmailURL(domain: self.viewModel.authenticateInfo.domain)

View File

@ -8,14 +8,10 @@
import UIKit
class PickServerCategoryCollectionViewCell: UICollectionViewCell {
var observations = Set<NSKeyValueObservation>()
var categoryView: PickServerCategoryView = {
let view = PickServerCategoryView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
var categoryView = PickServerCategoryView()
override func prepareForReuse() {
super.prepareForReuse()
@ -35,13 +31,15 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell {
extension PickServerCategoryCollectionViewCell {
private func configure() {
contentView.addSubview(categoryView)
backgroundColor = .clear
categoryView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(categoryView)
NSLayoutConstraint.activate([
categoryView.topAnchor.constraint(equalTo: contentView.topAnchor),
categoryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
categoryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
categoryView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor),
])
}
}

View File

@ -14,6 +14,7 @@ import AuthenticationServices
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
private var disposeBag = Set<AnyCancellable>()
private var observations = Set<NSKeyValueObservation>()
private var tableViewObservation: NSKeyValueObservation?
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
@ -31,21 +32,16 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
private let emptyStateView = PickServerEmptyStateView()
private var emptyStateViewLeadingLayoutConstraint: NSLayoutConstraint!
private var emptyStateViewTrailingLayoutConstraint: NSLayoutConstraint!
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self))
tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self))
tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self))
tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self))
tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.keyboardDismissMode = .onDrag
tableView.translatesAutoresizingMaskIntoConstraints = false
if #available(iOS 15.0, *) {
tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude
} else {
@ -54,14 +50,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
return tableView
}()
let buttonContainer = UIView()
let nextStepButton: PrimaryActionButton = {
let button = PrimaryActionButton()
button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
return button
let navigationActionView: NavigationActionView = {
let navigationActionView = NavigationActionView()
navigationActionView.backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
return navigationActionView
}()
var buttonContainerBottomLayoutConstraint: NSLayoutConstraint!
var mastodonAuthenticationController: MastodonAuthenticationController?
@ -72,16 +65,15 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
}
extension MastodonPickServerViewController {
extension MastodonPickServerViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem()
setupOnboardingAppearance()
defer { setupNavigationBarBackgroundView() }
configureTitleLabel()
configureMargin()
#if DEBUG
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil)
@ -94,26 +86,35 @@ extension MastodonPickServerViewController {
navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children)
#endif
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
buttonContainer.preservesSuperviewLayoutMargins = true
view.addSubview(buttonContainer)
buttonContainerBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: 0).priority(.defaultHigh)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
buttonContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
buttonContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: buttonContainer.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
buttonContainerBottomLayoutConstraint,
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
view.addSubview(nextStepButton)
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(navigationActionView)
defer {
view.bringSubviewToFront(navigationActionView)
}
NSLayoutConstraint.activate([
nextStepButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor),
nextStepButton.leadingAnchor.constraint(equalTo: buttonContainer.layoutMarginsGuide.leadingAnchor),
buttonContainer.layoutMarginsGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor),
nextStepButton.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor),
nextStepButton.heightAnchor.constraint(equalToConstant: MastodonPickServerViewController.actionButtonHeight).priority(.defaultHigh),
navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor),
])
navigationActionView
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
guard let self = self else { return }
let inset = navigationActionView.frame.height
self.tableView.contentInset.bottom = inset
}
.store(in: &observations)
// fix AutoLayout warning when observe before view appear
viewModel.viewWillAppear
.receive(on: DispatchQueue.main)
@ -125,26 +126,7 @@ extension MastodonPickServerViewController {
}
}
.store(in: &disposeBag)
tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableViewTopPaddingView)
tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh)
NSLayoutConstraint.activate([
tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableViewTopPaddingViewHeightLayoutConstraint,
])
tableViewTopPaddingView.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
buttonContainer.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7),
])
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(emptyStateView)
emptyStateViewLeadingLayoutConstraint = emptyStateView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor)
@ -153,64 +135,24 @@ extension MastodonPickServerViewController {
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
emptyStateViewLeadingLayoutConstraint,
emptyStateViewTrailingLayoutConstraint,
buttonContainer.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
navigationActionView.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
])
view.sendSubviewToBack(emptyStateView)
// update layout when keyboard show/dismiss
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
keyboardEventPublishers
.sink { [weak self] keyboardEvents in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
// guard external keyboard connected
guard isShow, state == .dock, GCKeyboard.coalesced != nil else {
self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight
return
}
let externalKeyboardToolbarHeight = self.view.frame.maxY - endFrame.minY
guard externalKeyboardToolbarHeight > 0 else {
self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight
return
}
UIView.animate(withDuration: 0.3) {
self.buttonContainerBottomLayoutConstraint.constant = externalKeyboardToolbarHeight + 16
self.view.layoutIfNeeded()
}
}
.store(in: &disposeBag)
switch viewModel.mode {
case .signIn:
nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal)
case .signUp:
nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal)
}
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
tableView.delegate = self
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
pickServerCategoriesCellDelegate: self,
pickServerSearchCellDelegate: self,
pickServerServerSectionTableHeaderViewDelegate: self,
pickServerCellDelegate: self
)
viewModel
.selectedServer
.map { $0 != nil }
.assign(to: \.isEnabled, on: nextStepButton)
.assign(to: \.isEnabled, on: navigationActionView.nextButton)
.store(in: &disposeBag)
Publishers.Merge(
viewModel.error,
authenticationViewModel.error
@ -229,7 +171,7 @@ extension MastodonPickServerViewController {
)
}
.store(in: &disposeBag)
authenticationViewModel
.authenticated
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
@ -249,17 +191,17 @@ extension MastodonPickServerViewController {
}
}
.store(in: &disposeBag)
authenticationViewModel.isAuthenticating
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuthenticating in
guard let self = self else { return }
isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading()
isAuthenticating ? self.navigationActionView.nextButton.showLoading() : self.navigationActionView.nextButton.stopLoading()
}
.store(in: &disposeBag)
viewModel.emptyStateViewState
.receive(on: RunLoop.main)
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
switch state {
@ -284,6 +226,9 @@ extension MastodonPickServerViewController {
}
}
.store(in: &disposeBag)
navigationActionView.backButton.addTarget(self, action: #selector(MastodonPickServerViewController.backButtonDidPressed(_:)), for: .touchUpInside)
navigationActionView.nextButton.addTarget(self, action: #selector(MastodonPickServerViewController.nextButtonDidPressed(_:)), for: .touchUpInside)
}
override func viewWillAppear(_ animated: Bool) {
@ -291,43 +236,31 @@ extension MastodonPickServerViewController {
viewModel.viewWillAppear.send()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
tableView.flashScrollIndicators()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setupNavigationBarAppearance()
updateEmptyStateViewLayout()
configureTitleLabel()
configureMargin()
}
}
extension MastodonPickServerViewController {
private func configureTitleLabel() {
guard UIDevice.current.userInterfaceIdiom == .pad else {
return
}
switch traitCollection.horizontalSizeClass {
case .regular:
navigationItem.largeTitleDisplayMode = .always
navigationItem.title = L10n.Scene.ServerPicker.title.replacingOccurrences(of: "\n", with: " ")
default:
navigationItem.largeTitleDisplayMode = .never
navigationItem.title = nil
}
}
}
extension MastodonPickServerViewController {
@objc
private func nextStepButtonDidClicked(_ sender: UIButton) {
@objc private func backButtonDidPressed(_ sender: UIButton) {
navigationController?.popViewController(animated: true)
}
@objc private func nextButtonDidPressed(_ sender: UIButton) {
switch viewModel.mode {
case .signIn:
doSignIn()
case .signUp:
doSignUp()
case .signIn: doSignIn()
case .signUp: doSignUp()
}
}
@ -442,8 +375,8 @@ extension MastodonPickServerViewController {
self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show)
} else {
let mastodonRegisterViewModel = MastodonRegisterViewModel(
domain: server.domain,
context: self.context,
domain: server.domain,
authenticateInfo: response.authenticateInfo,
instance: response.instance.value,
applicationToken: response.applicationToken.value
@ -458,16 +391,6 @@ extension MastodonPickServerViewController {
// MARK: - UITableViewDelegate
extension MastodonPickServerViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === tableView else { return }
let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
if offsetY < 0 {
tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY)
} else {
tableViewTopPaddingViewHeightLayoutConstraint.constant = 0
}
}
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
@ -500,87 +423,89 @@ extension MastodonPickServerViewController: UITableViewDelegate {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .categoryPicker:
guard let cell = cell as? PickServerCategoriesCell else { return }
guard let diffableDataSource = cell.diffableDataSource else { return }
let snapshot = diffableDataSource.snapshot()
let item = viewModel.selectCategoryItem.value
guard let section = snapshot.indexOfSection(.main),
let row = snapshot.indexOfItem(item) else { return }
cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
case .search:
guard let cell = cell as? PickServerSearchCell else { return }
cell.searchTextField.text = viewModel.searchText.value
// case .categoryPicker:
// guard let cell = cell as? PickServerCategoriesCell else { return }
// guard let diffableDataSource = cell.diffableDataSource else { return }
// let snapshot = diffableDataSource.snapshot()
//
// let item = viewModel.selectCategoryItem.value
// guard let section = snapshot.indexOfSection(.main),
// let row = snapshot.indexOfItem(item) else { return }
// cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
// case .search:
// guard let cell = cell as? PickServerSearchCell else { return }
// cell.searchTextField.text = viewModel.searchText.value
default:
break
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let snapshot = diffableDataSource.snapshot()
guard section < snapshot.numberOfSections else { return nil }
let section = snapshot.sectionIdentifiers[section]
switch section {
case .servers:
return viewModel.serverSectionHeaderView
default:
return UIView()
}
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return .leastNonzeroMagnitude }
let snapshot = diffableDataSource.snapshot()
guard section < snapshot.numberOfSections else { return .leastNonzeroMagnitude }
let section = snapshot.sectionIdentifiers[section]
switch section {
case .servers:
return PickServerServerSectionTableHeaderView.height
default:
return .leastNonzeroMagnitude
}
}
}
extension MastodonPickServerViewController {
private func updateEmptyStateViewLayout() {
guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
let rectInTableView = tableView.rectForRow(at: indexPath)
emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
switch traitCollection.horizontalSizeClass {
case .regular:
emptyStateViewLeadingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
emptyStateViewTrailingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
default:
let margin = tableView.layoutMarginsGuide.layoutFrame.origin.x
emptyStateViewLeadingLayoutConstraint.constant = margin
emptyStateViewTrailingLayoutConstraint.constant = margin
}
}
private func configureMargin() {
switch traitCollection.horizontalSizeClass {
case .regular:
let margin = MastodonPickServerViewController.viewEdgeMargin
buttonContainer.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
default:
buttonContainer.layoutMargins = .zero
}
// guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
// guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
// let rectInTableView = tableView.rectForRow(at: indexPath)
//
// emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
//
// switch traitCollection.horizontalSizeClass {
// case .regular:
// emptyStateViewLeadingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
// emptyStateViewTrailingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
// default:
// let margin = tableView.layoutMarginsGuide.layoutFrame.origin.x
// emptyStateViewLeadingLayoutConstraint.constant = margin
// emptyStateViewTrailingLayoutConstraint.constant = margin
// }
}
}
// MARK: - PickServerCategoriesCellDelegate
extension MastodonPickServerViewController: PickServerCategoriesCellDelegate {
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let diffableDataSource = cell.diffableDataSource else { return }
// MARK: - PickServerServerSectionTableHeaderViewDelegate
extension MastodonPickServerViewController: PickServerServerSectionTableHeaderViewDelegate {
func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let diffableDataSource = headerView.diffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
viewModel.selectCategoryItem.value = item ?? .all
}
}
// MARK: - PickServerSearchCellDelegate
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, searchTextDidChange searchText: String?) {
viewModel.searchText.send(searchText ?? "")
}
}
// MARK: - PickServerCellDelegate
extension MastodonPickServerViewController: PickServerCellDelegate {
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .server(_, attribute) = item else { return }
attribute.isExpand.toggle()
tableView.beginUpdates()
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
tableView.endUpdates()
// expand attribute change do not needs apply snapshot to diffable data source
// but should I block the viewModel data binding during tableView.beginUpdates/endUpdates?
}
}
// MARK: - OnboardingViewControllerAppearance

View File

@ -6,32 +6,105 @@
//
import UIKit
import Combine
extension MastodonPickServerViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerServerSectionTableHeaderViewDelegate: PickServerServerSectionTableHeaderViewDelegate,
pickServerCellDelegate: PickServerCellDelegate
) {
// set section header
serverSectionHeaderView.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
for: serverSectionHeaderView.collectionView,
dependency: dependency
)
var sectionHeaderSnapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
sectionHeaderSnapshot.appendSections([.main])
sectionHeaderSnapshot.appendItems(categoryPickerItems, toSection: .main)
serverSectionHeaderView.delegate = pickServerServerSectionTableHeaderViewDelegate
serverSectionHeaderView.diffableDataSource?.applySnapshot(sectionHeaderSnapshot, animated: false) { [weak self] in
guard let self = self else { return }
guard let indexPath = self.serverSectionHeaderView.diffableDataSource?.indexPath(for: .all) else { return }
self.serverSectionHeaderView.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
}
// set tableView
diffableDataSource = PickServerSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: pickServerSearchCellDelegate,
pickServerCellDelegate: pickServerCellDelegate
)
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
snapshot.appendSections([.header, .category, .search, .servers])
snapshot.appendSections([.header, .servers])
snapshot.appendItems([.header], toSection: .header)
snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category)
snapshot.appendItems([.search], toSection: .search)
diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self)
Publishers.CombineLatest(
filteredIndexedServers,
unindexedServers
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let oldSnapshot = diffableDataSource.snapshot()
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .server(server, attribute) = item else { continue }
oldSnapshotServerItemAttributeDict[server.domain] = attribute
}
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
snapshot.appendSections([.header, .servers])
snapshot.appendItems([.header], toSection: .header)
// TODO: handle filter
var serverItems: [PickServerItem] = []
for server in indexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast.value = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
if let unindexedServers = unindexedServers {
if !unindexedServers.isEmpty {
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast.value = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
} else {
if indexedServers.isEmpty && !self.isLoadingIndexedServers.value {
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true)))
}
}
} else {
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false)))
}
if case let .server(_, attribute) = serverItems.last {
attribute.isLast.value = true
}
if case let .loader(attribute) = serverItems.last {
attribute.isLast = true
}
snapshot.appendItems(serverItems, toSection: .servers)
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
})
.store(in: &disposeBag)
}
}

View File

@ -12,6 +12,7 @@ import GameplayKit
import MastodonSDK
import CoreDataStack
import OrderedCollections
import Tabman
class MastodonPickServerViewModel: NSObject {
@ -27,6 +28,8 @@ class MastodonPickServerViewModel: NSObject {
}
var disposeBag = Set<AnyCancellable>()
let serverSectionHeaderView = PickServerServerSectionTableHeaderView()
// input
let mode: PickServerMode
@ -82,68 +85,6 @@ class MastodonPickServerViewModel: NSObject {
extension MastodonPickServerViewModel {
private func configure() {
Publishers.CombineLatest(
filteredIndexedServers,
unindexedServers
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let oldSnapshot = diffableDataSource.snapshot()
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .server(server, attribute) = item else { continue }
oldSnapshotServerItemAttributeDict[server.domain] = attribute
}
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
snapshot.appendSections([.header, .category, .search, .servers])
snapshot.appendItems([.header], toSection: .header)
snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category)
snapshot.appendItems([.search], toSection: .search)
// TODO: handle filter
var serverItems: [PickServerItem] = []
for server in indexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast.value = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
if let unindexedServers = unindexedServers {
if !unindexedServers.isEmpty {
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast.value = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
} else {
if indexedServers.isEmpty && !self.isLoadingIndexedServers.value {
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true)))
}
}
} else {
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false)))
}
if case let .server(_, attribute) = serverItems.last {
attribute.isLast.value = true
}
if case let .loader(attribute) = serverItems.last {
attribute.isLast = true
}
snapshot.appendItems(serverItems, toSection: .servers)
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
})
.store(in: &disposeBag)
Publishers.CombineLatest(
isLoadingIndexedServers,
loadingIndexedServersError
@ -301,3 +242,12 @@ extension MastodonPickServerViewModel {
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
}
}
// MARK: - TMBarDataSource
extension MastodonPickServerViewModel: TMBarDataSource {
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
let item = categoryPickerItems[index]
let barItem = TMBarItem(title: item.title)
return barItem
}
}

View File

@ -1,145 +0,0 @@
//
// PickServerCategoriesCell.swift
// Mastodon
//
// Created by BradGao on 2021/2/23.
//
import os.log
import UIKit
import MastodonSDK
protocol PickServerCategoriesCellDelegate: AnyObject {
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
}
final class PickServerCategoriesCell: UITableViewCell {
weak var delegate: PickServerCategoriesCellDelegate?
var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
let metricView = UIView()
let collectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension PickServerCategoriesCell {
private func _init() {
selectionStyle = .none
backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
configureMargin()
metricView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(metricView)
NSLayoutConstraint.activate([
metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
metricView.topAnchor.constraint(equalTo: contentView.topAnchor),
metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
])
contentView.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20),
collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
])
collectionView.delegate = self
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
configureMargin()
}
override func layoutSubviews() {
super.layoutSubviews()
collectionView.collectionViewLayout.invalidateLayout()
}
}
extension PickServerCategoriesCell {
private func configureMargin() {
switch traitCollection.horizontalSizeClass {
case .regular:
let margin = MastodonPickServerViewController.viewEdgeMargin
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
default:
contentView.layoutMargins = .zero
}
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
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)
delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
layoutIfNeeded()
return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 16
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: 60, height: 80)
}
}
extension PickServerCategoriesCell {
override func accessibilityElementCount() -> Int {
guard let diffableDataSource = diffableDataSource else { return 0 }
return diffableDataSource.snapshot().itemIdentifiers.count
}
override func accessibilityElement(at index: Int) -> Any? {
guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
return item
}
}

View File

@ -13,7 +13,7 @@ import AlamofireImage
import Kanna
protocol PickServerCellDelegate: AnyObject {
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
// func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
}
class PickServerCell: UITableViewCell {
@ -21,20 +21,17 @@ class PickServerCell: UITableViewCell {
weak var delegate: PickServerCellDelegate?
var disposeBag = Set<AnyCancellable>()
let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse)
let containerView: UIView = {
let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
let containerView: UIStackView = {
let view = UIStackView()
view.axis = .vertical
view.spacing = 4
return view
}()
let domainLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
@ -52,7 +49,7 @@ class PickServerCell: UITableViewCell {
let descriptionLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
label.numberOfLines = 0
label.textColor = Asset.Colors.Label.primary.color
label.adjustsFontForContentSizeCategory = true
@ -60,112 +57,33 @@ class PickServerCell: UITableViewCell {
return label
}()
let thumbnailActivityIndicator = UIActivityIndicatorView(style: .medium)
let thumbnailImageView: UIImageView = {
let imageView = UIImageView()
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
let infoStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .fill
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = 16
return stackView
}()
let expandBox: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let expandButton: UIButton = {
let button = HitTestExpandedButton(type: .custom)
button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular)
button.translatesAutoresizingMaskIntoConstraints = false
button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1)
button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1)
button.transform = CGAffineTransform(scaleX: -1, y: 1)
return button
}()
let separator: UIView = {
let view = UIView()
view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = Asset.Theme.System.separator.color
return view
}()
let langValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let usersValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
label.textAlignment = .center
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let categoryValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let langTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
label.text = L10n.Scene.ServerPicker.Label.language
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let usersTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
label.text = L10n.Scene.ServerPicker.Label.users
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let categoryTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
label.text = L10n.Scene.ServerPicker.Label.category
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
@ -175,9 +93,6 @@ class PickServerCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
thumbnailImageView.isHidden = false
thumbnailImageView.af.cancelImageRequest()
thumbnailActivityIndicator.stopAnimating()
disposeBag.removeAll()
}
@ -197,172 +112,55 @@ class PickServerCell: UITableViewCell {
extension PickServerCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
configureMargin()
backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
checkbox.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(checkbox)
NSLayoutConstraint.activate([
checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 1),
checkbox.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1),
checkbox.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1),
])
containerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerView)
containerView.addSubview(domainLabel)
containerView.addSubview(checkbox)
containerView.addSubview(descriptionLabel)
containerView.addSubview(separator)
containerView.addSubview(expandButton)
// Always add the expandbox which contains elements only visible in expand mode
containerView.addSubview(expandBox)
expandBox.addSubview(thumbnailImageView)
expandBox.addSubview(infoStackView)
expandBox.isHidden = true
let verticalInfoStackViewLang = makeVerticalInfoStackView(arrangedView: langValueLabel, langTitleLabel)
let verticalInfoStackViewUsers = makeVerticalInfoStackView(arrangedView: usersValueLabel, usersTitleLabel)
let verticalInfoStackViewCategory = makeVerticalInfoStackView(arrangedView: categoryValueLabel, categoryTitleLabel)
infoStackView.addArrangedSubview(verticalInfoStackViewLang)
infoStackView.addArrangedSubview(verticalInfoStackViewUsers)
infoStackView.addArrangedSubview(verticalInfoStackViewCategory)
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1)
collapseConstraints.append(expandButtonTopConstraintInCollapse)
let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh)
expandConstraints.append(expandButtonTopConstraintInExpand)
NSLayoutConstraint.activate([
// Set background view
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// Set bottom separator
separator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: separator.trailingAnchor),
containerView.topAnchor.constraint(equalTo: separator.topAnchor),
separator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh),
domainLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor),
domainLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
checkbox.widthAnchor.constraint(equalToConstant: 23),
checkbox.heightAnchor.constraint(equalToConstant: 22),
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor),
checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16),
checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor),
descriptionLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
descriptionLabel.topAnchor.constraint(equalTo: domainLabel.bottomAnchor, constant: 8),
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: descriptionLabel.trailingAnchor),
// Set expandBox constraints
expandBox.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandBox.trailingAnchor),
expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh),
thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor),
thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor),
infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16),
expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor),
containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor),
containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11),
containerView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 22),
containerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 11),
checkbox.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
thumbnailActivityIndicator.translatesAutoresizingMaskIntoConstraints = false
thumbnailImageView.addSubview(thumbnailActivityIndicator)
containerView.addArrangedSubview(domainLabel)
containerView.addArrangedSubview(descriptionLabel)
containerView.setCustomSpacing(6, after: descriptionLabel)
containerView.addArrangedSubview(infoStackView)
infoStackView.addArrangedSubview(usersValueLabel)
infoStackView.addArrangedSubview(langValueLabel)
infoStackView.addArrangedSubview(UIView())
separator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separator)
NSLayoutConstraint.activate([
thumbnailActivityIndicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor),
thumbnailActivityIndicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor),
separator.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: separator.trailingAnchor),
separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1),
])
thumbnailActivityIndicator.hidesWhenStopped = true
thumbnailActivityIndicator.stopAnimating()
NSLayoutConstraint.activate(collapseConstraints)
domainLabel.setContentHuggingPriority(.required - 1, for: .vertical)
domainLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical)
descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical)
expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
configureMargin()
}
private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.distribution = .equalCentering
stackView.spacing = 2
arrangedView.forEach { stackView.addArrangedSubview($0) }
return stackView
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
checkbox.tintColor = Asset.Colors.Label.primary.color
} else {
checkbox.image = UIImage(systemName: "circle")
checkbox.tintColor = Asset.Colors.Label.secondary.color
}
}
@objc
private func expandButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.pickServerCell(self, expandButtonPressed: sender)
}
}
extension PickServerCell {
private func configureMargin() {
switch traitCollection.horizontalSizeClass {
case .regular:
let margin = MastodonPickServerViewController.viewEdgeMargin
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
default:
contentView.layoutMargins = .zero
}
}
}
extension PickServerCell {
enum ExpandMode {
case collapse
case expand
}
func updateExpandMode(mode: ExpandMode) {
switch mode {
case .collapse:
expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
expandBox.isHidden = true
expandButton.isSelected = false
NSLayoutConstraint.deactivate(expandConstraints)
NSLayoutConstraint.activate(collapseConstraints)
case .expand:
expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal)
expandBox.isHidden = false
expandButton.isSelected = true
NSLayoutConstraint.activate(expandConstraints)
NSLayoutConstraint.deactivate(collapseConstraints)
}
expandMode.value = mode
}
}

View File

@ -13,15 +13,7 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
let containerView: UIView = {
let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let seperator: UIView = {
let view = UIView()
view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
return view
}()
@ -30,30 +22,22 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
label.text = L10n.Scene.ServerPicker.EmptyState.noResults
label.textColor = Asset.Colors.Label.secondary.color
label.textAlignment = .center
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19)
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold))
return label
}()
override func _init() {
super._init()
configureMargin()
contentView.addSubview(containerView)
contentView.addSubview(seperator)
// Set background view
containerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerView)
NSLayoutConstraint.activate([
// Set background view
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1),
// Set bottom separator
seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor),
containerView.topAnchor.constraint(equalTo: seperator.topAnchor),
seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh),
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false
@ -69,24 +53,7 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
activityIndicatorView.isHidden = false
startAnimating()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
configureMargin()
}
}
extension PickServerLoaderTableViewCell {
private func configureMargin() {
switch traitCollection.horizontalSizeClass {
case .regular:
let margin = MastodonPickServerViewController.viewEdgeMargin
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
default:
contentView.layoutMargins = .zero
}
}
}
#if canImport(SwiftUI) && DEBUG

Some files were not shown because too many files have changed in this diff Show More