feat: add picker server loader. Set chevron image for expand button

This commit is contained in:
CMK 2021-05-13 17:50:37 +08:00
parent 0228f409a8
commit 46baa59d37
14 changed files with 192 additions and 20 deletions

View File

@ -199,7 +199,8 @@
},
"empty_state": {
"finding_servers": "Finding available servers...",
"bad_network": "Something went wrong while loading data. Check your internet connection."
"bad_network": "Something went wrong while loading data. Check your internet connection.",
"no_results": "No results"
}
},
"register": {

View File

@ -191,6 +191,7 @@
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; };
DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; };
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; };
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; };
@ -758,6 +759,7 @@
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = "<group>"; };
DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = "<group>"; };
@ -1136,6 +1138,7 @@
0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */,
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */,
0FB3D33725E6401400AAD544 /* PickServerCell.swift */,
DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
@ -1390,6 +1393,7 @@
2D7631A425C1532200929FB9 /* Share */ = {
isa = PBXGroup;
children = (
5D03938E2612D200007FE196 /* Webview */,
DB68A04F25E9028800CFDF14 /* NavigationController */,
DB9D6C2025E502C60051B173 /* ViewModel */,
2D7631A525C1532D00929FB9 /* View */,
@ -2047,7 +2051,6 @@
DB8AF55525C1379F002E6C99 /* Scene */ = {
isa = PBXGroup;
children = (
5D03938E2612D200007FE196 /* Webview */,
2D7631A425C1532200929FB9 /* Share */,
DB6180E426391A500018D199 /* Transition */,
DB8AF54E25C13703002E6C99 /* MainTab */,
@ -2983,6 +2986,7 @@
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */,
2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */,
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */,

View File

@ -14,6 +14,7 @@ enum PickServerItem {
case categoryPicker(items: [CategoryPickerItem])
case search
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
case loader(attribute: LoaderItemAttribute)
}
extension PickServerItem {
@ -34,6 +35,26 @@ extension PickServerItem {
hasher.combine(isExpand)
}
}
final class LoaderItemAttribute: Equatable, Hashable {
let id = UUID()
var isLast: Bool
var isNoResult: Bool
init(isLast: Bool, isEmptyResult: Bool) {
self.isLast = isLast
self.isNoResult = isEmptyResult
}
static func == (lhs: PickServerItem.LoaderItemAttribute, rhs: PickServerItem.LoaderItemAttribute) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension PickServerItem: Equatable {
@ -47,6 +68,8 @@ extension PickServerItem: Equatable {
return true
case (.server(let serverLeft, _), .server(let serverRight, _)):
return serverLeft.domain == serverRight.domain
case (.loader(let attributeLeft), loader(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
@ -64,6 +87,8 @@ extension PickServerItem: Hashable {
hasher.combine(String(describing: PickServerItem.search.self))
case .server(let server, _):
hasher.combine(server.domain)
case .loader(let attribute):
hasher.combine(attribute)
}
}
}

View File

@ -57,6 +57,10 @@ extension PickServerSection {
PickServerSection.configure(cell: cell, server: server, attribute: attribute)
cell.delegate = pickServerCellDelegate
return cell
case .loader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell
PickServerSection.configure(cell: cell, attribute: attribute)
return cell
}
}
}
@ -137,3 +141,23 @@ extension PickServerSection {
}
}
extension PickServerSection {
static func configure(cell: PickServerLoaderTableViewCell, attribute: PickServerItem.LoaderItemAttribute) {
if attribute.isLast {
cell.containerView.layer.maskedCorners = [
.layerMinXMaxYCorner,
.layerMaxXMaxYCorner
]
cell.containerView.layer.cornerCurve = .continuous
cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
} else {
cell.containerView.layer.cornerRadius = 0
}
attribute.isNoResult ? cell.stopAnimating() : cell.startAnimating()
cell.emptyStatusLabel.isHidden = !attribute.isNoResult
}
}

View File

@ -773,6 +773,8 @@ internal enum L10n {
internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork")
/// Finding available servers...
internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers")
/// No results
internal static let noResults = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.NoResults")
}
internal enum Input {
/// Find a server or join your own...

View File

@ -257,6 +257,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Button.SeeMore" = "See More";
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
"Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE";

View File

@ -257,6 +257,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Button.SeeMore" = "See More";
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
"Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE";

View File

@ -31,6 +31,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self))
tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.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

View File

@ -39,7 +39,7 @@ class MastodonPickServerViewModel: NSObject {
let selectCategoryItem = CurrentValueSubject<CategoryPickerItem, Never>(.all)
let searchText = CurrentValueSubject<String, Never>("")
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading
let viewWillAppear = PassthroughSubject<Void, Never>()
// output
@ -85,8 +85,8 @@ extension MastodonPickServerViewModel {
private func configure() {
Publishers.CombineLatest(
filteredIndexedServers.eraseToAnyPublisher(),
unindexedServers.eraseToAnyPublisher()
filteredIndexedServers,
unindexedServers
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
@ -114,16 +114,31 @@ extension MastodonPickServerViewModel {
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast = 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 = 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 = true
}
if case let .loader(attribute) = serverItems.last {
attribute.isLast = true
}
snapshot.appendItems(serverItems, toSection: .servers)
diffableDataSource.defaultRowAnimation = .fade
@ -168,6 +183,7 @@ extension MastodonPickServerViewModel {
guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else {
return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher()
}
self.unindexedServers.value = nil
return self.context.apiService.instance(domain: domain)
.map { response -> Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>in
let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] }
@ -184,9 +200,14 @@ extension MastodonPickServerViewModel {
switch result {
case .success(let response):
self.unindexedServers.send(response.value)
case .failure:
// TODO: What should be presented when user inputs invalid search text?
self.unindexedServers.send([])
case .failure(let error):
if let error = error as? APIService.APIError,
case let .implicit(reason) = error,
case .badRequest = reason {
self.unindexedServers.send([])
} else {
self.unindexedServers.send(nil)
}
}
})
.store(in: &disposeBag)

View File

@ -88,11 +88,14 @@ class PickServerCell: UITableViewCell {
let expandButton: UIButton = {
let button = UIButton(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.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
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
}()
@ -325,11 +328,15 @@ extension PickServerCell {
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)

View File

@ -0,0 +1,86 @@
//
// PickServerLoaderTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-13.
//
import UIKit
import Combine
final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
let containerView: UIView = {
let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let seperator: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let emptyStatusLabel: UILabel = {
let label = UILabel()
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)
return label
}()
override func _init() {
super._init()
contentView.addSubview(containerView)
contentView.addSubview(seperator)
NSLayoutConstraint.activate([
// Set background view
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.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),
])
emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(emptyStatusLabel)
NSLayoutConstraint.activate([
emptyStatusLabel.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor),
containerView.readableContentGuide.trailingAnchor.constraint(equalTo: emptyStatusLabel.trailingAnchor),
emptyStatusLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
emptyStatusLabel.isHidden = true
contentView.bringSubviewToFront(stackView)
activityIndicatorView.isHidden = false
startAnimating()
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct PickServerLoaderTableViewCell_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
PickServerLoaderTableViewCell()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -16,9 +16,9 @@ class TimelineLoaderTableViewCell: UITableViewCell {
static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium))
var disposeBag = Set<AnyCancellable>()
var stateBindDispose: AnyCancellable?
let stackView = UIStackView()
let loadMoreButton: UIButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont
@ -86,7 +86,6 @@ class TimelineLoaderTableViewCell: UITableViewCell {
])
// use stack view to alignlment content center
let stackView = UIStackView()
stackView.spacing = 4
stackView.axis = .horizontal
stackView.alignment = .center