feat: [WIP] display empty state when fetching server list

This commit is contained in:
CMK 2021-03-06 12:55:52 +08:00
parent 54c7610c7f
commit e70fd532c4
13 changed files with 337 additions and 55 deletions

View File

@ -64,6 +64,10 @@
}, },
"input": { "input": {
"placeholder": "Find a server or join your own..." "placeholder": "Find a server or join your own..."
},
"empty_state": {
"finding_servers": "Finding available servers...",
"bad_network": "Something went wrong while loading data. Check your internet connection."
} }
}, },
"register": { "register": {
@ -150,4 +154,4 @@
"title": "Public" "title": "Public"
} }
} }
} }

View File

@ -156,6 +156,7 @@
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; };
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; };
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; };
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; };
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
@ -383,6 +384,7 @@
DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = "<group>"; }; DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = "<group>"; };
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = "<group>"; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = "<group>"; };
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = "<group>"; };
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
@ -494,6 +496,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */,
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */,
); );
path = View; path = View;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1554,6 +1557,7 @@
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import MastodonSDK import MastodonSDK
import Kanna import Kanna
import AlamofireImage
enum PickServerSection: Equatable, Hashable { enum PickServerSection: Equatable, Hashable {
case header case header
@ -82,9 +83,41 @@ extension PickServerSection {
cell.categoryValueLabel.text = server.category.uppercased() cell.categoryValueLabel.text = server.category.uppercased()
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
// UIView.animate(withDuration: 0.33) {
// cell.expandBox.layoutIfNeeded() 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.thumbnailActivityIdicator.stopAnimating()
return
}
cell.thumbnailImageView.isHidden = false
cell.thumbnailActivityIdicator.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?.thumbnailActivityIdicator.stopAnimating()
}
}
)
}
}
.store(in: &cell.disposeBag)
} }
private static func parseUsersCount(_ usersCount: Int) -> String { private static func parseUsersCount(_ usersCount: Int) -> String {

View File

@ -236,6 +236,12 @@ internal enum L10n {
internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All")
} }
} }
internal enum EmptyState {
/// Something went wrong while loading data. Check your internet connection.
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")
}
internal enum Input { internal enum Input {
/// Find a server or join your own... /// Find a server or join your own...
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")

View File

@ -68,6 +68,8 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.Category.All" = "All";
"Scene.ServerPicker.Button.Seeless" = "See Less"; "Scene.ServerPicker.Button.Seeless" = "See Less";
"Scene.ServerPicker.Button.Seemore" = "See More"; "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.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
"Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Language" = "LANGUAGE";

View File

@ -12,16 +12,19 @@ import Combine
final class MastodonPickServerViewController: UIViewController, NeedsDependency { final class MastodonPickServerViewController: UIViewController, NeedsDependency {
private var disposeBag = Set<AnyCancellable>() private var disposeBag = Set<AnyCancellable>()
private var tableViewObservation: NSKeyValueObservation?
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: MastodonPickServerViewModel! var viewModel: MastodonPickServerViewModel!
private var isAuthenticating = CurrentValueSubject<Bool, Never>(false)
private var expandServerDomainSet = Set<String>() private var expandServerDomainSet = Set<String>()
let emptyStateView = PickServerEmptyStateView()
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
let tableView: UITableView = { let tableView: UITableView = {
let tableView = ControlContainableTableView() let tableView = ControlContainableTableView()
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
@ -45,6 +48,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
}() }()
deinit { deinit {
tableViewObservation = nil
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
} }
@ -52,10 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
extension MastodonPickServerViewController { extension MastodonPickServerViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -70,6 +70,38 @@ extension MastodonPickServerViewController {
view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
]) ])
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(emptyStateView)
NSLayoutConstraint.activate([
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 7)
])
// fix AutoLayout warning when observe before view appear
viewModel.viewWillAppear
.receive(on: DispatchQueue.main)
.sink { [weak self] in
guard let self = self else { return }
self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in
guard let self = self else { return }
self.updateEmptyStateViewLayout()
}
}
.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.Colors.Background.systemGroupedBackground.color
view.addSubview(tableView) view.addSubview(tableView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.topAnchor.constraint(equalTo: view.topAnchor),
@ -135,15 +167,50 @@ extension MastodonPickServerViewController {
} }
.store(in: &disposeBag) .store(in: &disposeBag)
isAuthenticating viewModel.isAuthenticating
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] isAuthenticating in .sink { [weak self] isAuthenticating in
guard let self = self else { return } guard let self = self else { return }
isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading()
} }
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.emptyStateViewState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
switch state {
case .none:
self.emptyStateView.networkIndicatorImageView.isHidden = true
self.emptyStateView.activityIndicatorView.stopAnimating()
self.emptyStateView.infoLabel.isHidden = true
case .loading:
self.emptyStateView.networkIndicatorImageView.isHidden = true
self.emptyStateView.activityIndicatorView.startAnimating()
self.emptyStateView.infoLabel.isHidden = false
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left
case .badNetwork:
self.emptyStateView.networkIndicatorImageView.isHidden = false
self.emptyStateView.activityIndicatorView.stopAnimating()
self.emptyStateView.infoLabel.isHidden = false
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
self.emptyStateView.infoLabel.textAlignment = .center
}
}
.store(in: &disposeBag)
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.viewWillAppear.send()
}
}
extension MastodonPickServerViewController {
@objc @objc
private func nextStepButtonDidClicked(_ sender: UIButton) { private func nextStepButtonDidClicked(_ sender: UIButton) {
switch viewModel.mode { switch viewModel.mode {
@ -156,7 +223,7 @@ extension MastodonPickServerViewController {
private func doSignIn() { private func doSignIn() {
guard let server = viewModel.selectedServer.value else { return } guard let server = viewModel.selectedServer.value else { return }
isAuthenticating.send(true) viewModel.isAuthenticating.send(true)
context.apiService.createApplication(domain: server.domain) context.apiService.createApplication(domain: server.domain)
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in .tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
let application = response.value let application = response.value
@ -168,7 +235,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] completion in .sink { [weak self] completion in
guard let self = self else { return } guard let self = self else { return }
self.isAuthenticating.send(false) self.viewModel.isAuthenticating.send(false)
switch completion { switch completion {
case .failure(let error): case .failure(let error):
@ -196,7 +263,7 @@ extension MastodonPickServerViewController {
private func doSignUp() { private func doSignUp() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let server = viewModel.selectedServer.value else { return } guard let server = viewModel.selectedServer.value else { return }
isAuthenticating.send(true) viewModel.isAuthenticating.send(true)
context.apiService.instance(domain: server.domain) context.apiService.instance(domain: server.domain)
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in .compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
@ -232,7 +299,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] completion in .sink { [weak self] completion in
guard let self = self else { return } guard let self = self else { return }
self.isAuthenticating.send(false) self.viewModel.isAuthenticating.send(false)
switch completion { switch completion {
case .failure(let error): case .failure(let error):
@ -266,10 +333,23 @@ extension MastodonPickServerViewController {
} }
} }
// MARK: - UITableViewDelegate
extension MastodonPickServerViewController: 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, viewForHeaderInSection section: Int) -> UIView? { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return UIView() let headerView = UIView()
headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
return headerView
} }
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
@ -320,6 +400,15 @@ extension MastodonPickServerViewController: UITableViewDelegate {
} }
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
}
}
//extension MastodonPickServerViewController: UITableViewDataSource { //extension MastodonPickServerViewController: UITableViewDataSource {
// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

View File

@ -41,6 +41,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
viewModel.isLoadingIndexedServers.value = true
viewModel.context.apiService.servers(language: nil, category: nil) viewModel.context.apiService.servers(language: nil, category: nil)
.sink { completion in .sink { completion in
switch completion { switch completion {
@ -67,9 +68,9 @@ extension MastodonPickServerViewModel.LoadIndexedServerState {
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } guard let stateMachine = self.stateMachine else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return } guard let _ = self else { return }
stateMachine.enter(Loading.self) stateMachine.enter(Loading.self)
} }
} }
@ -79,6 +80,13 @@ extension MastodonPickServerViewModel.LoadIndexedServerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false return false
} }
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
viewModel.isLoadingIndexedServers.value = false
}
} }
} }

View File

@ -13,11 +13,18 @@ import MastodonSDK
import CoreDataStack import CoreDataStack
class MastodonPickServerViewModel: NSObject { class MastodonPickServerViewModel: NSObject {
enum PickServerMode { enum PickServerMode {
case signUp case signUp
case signIn case signIn
} }
enum EmptyStateViewState {
case none
case loading
case badNetwork
}
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// input // input
@ -33,6 +40,7 @@ class MastodonPickServerViewModel: NSObject {
let searchText = CurrentValueSubject<String?, Never>(nil) let searchText = CurrentValueSubject<String?, Never>(nil)
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([])
let viewWillAppear = PassthroughSubject<Void, Never>()
// output // output
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>? var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
@ -47,12 +55,15 @@ class MastodonPickServerViewModel: NSObject {
stateMachine.enter(LoadIndexedServerState.Initial.self) stateMachine.enter(LoadIndexedServerState.Initial.self)
return stateMachine return stateMachine
}() }()
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil) let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>() let error = PassthroughSubject<Error, Never>()
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
var mastodonPinBasedAuthenticationViewController: UIViewController? var mastodonPinBasedAuthenticationViewController: UIViewController?
init(context: AppContext, mode: PickServerMode) { init(context: AppContext, mode: PickServerMode) {
@ -99,6 +110,16 @@ class MastodonPickServerViewModel: NSObject {
}) })
.store(in: &disposeBag) .store(in: &disposeBag)
isLoadingIndexedServers
.map { isLoadingIndexedServers -> EmptyStateViewState in
if isLoadingIndexedServers {
return .loading
} else {
return .none
}
}
.assign(to: \.value, on: emptyStateViewState)
.store(in: &disposeBag)
// Publishers.CombineLatest3( // Publishers.CombineLatest3(
// selectCategoryIndex, // selectCategoryIndex,

View File

@ -54,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell {
extension PickServerCategoriesCell { extension PickServerCategoriesCell {
private func _init() { private func _init() {
self.selectionStyle = .none selectionStyle = .none
backgroundColor = .clear backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
metricView.translatesAutoresizingMaskIntoConstraints = false metricView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(metricView) contentView.addSubview(metricView)

View File

@ -7,6 +7,7 @@
import os.log import os.log
import UIKit import UIKit
import Combine
import MastodonSDK import MastodonSDK
import AlamofireImage import AlamofireImage
import Kanna import Kanna
@ -19,6 +20,10 @@ class PickServerCell: UITableViewCell {
weak var delegate: PickServerCellDelegate? weak var delegate: PickServerCellDelegate?
var disposeBag = Set<AnyCancellable>()
let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse)
let containerView: UIView = { let containerView: UIView = {
let view = UIView() let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
@ -170,6 +175,7 @@ class PickServerCell: UITableViewCell {
thumbnailImageView.isHidden = false thumbnailImageView.isHidden = false
thumbnailImageView.af.cancelImageRequest() thumbnailImageView.af.cancelImageRequest()
thumbnailActivityIdicator.stopAnimating() thumbnailActivityIdicator.stopAnimating()
disposeBag.removeAll()
} }
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@ -329,34 +335,8 @@ extension PickServerCell {
NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.activate(expandConstraints)
NSLayoutConstraint.deactivate(collapseConstraints) NSLayoutConstraint.deactivate(collapseConstraints)
} }
expandMode.value = mode
} }
// private func updateThumbnail() {
// guard let serverInfo = server,
// let proxiedThumbnail = serverInfo.proxiedThumbnail,
// let url = URL(string: proxiedThumbnail) else {
// thumbnailImageView.isHidden = true
// thumbnailActivityIdicator.stopAnimating()
// return
// }
//
// thumbnailImageView.isHidden = false
// thumbnailActivityIdicator.startAnimating()
//
// let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true)
// thumbnailImageView.af.setImage(
// withURL: url,
// placeholderImage: placeholderImage,
// filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3),
// imageTransition: .crossDissolve(0.33),
// completion: { [weak self] response in
// guard let self = self else { return }
// switch response.result {
// case .success, .failure:
// self.thumbnailActivityIdicator.stopAnimating()
// }
// }
// )
// }
} }

View File

@ -74,8 +74,8 @@ class PickServerSearchCell: UITableViewCell {
extension PickServerSearchCell { extension PickServerSearchCell {
private func _init() { private func _init() {
self.selectionStyle = .none selectionStyle = .none
backgroundColor = .clear backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)

View File

@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell {
extension PickServerTitleCell { extension PickServerTitleCell {
private func _init() { private func _init() {
self.selectionStyle = .none selectionStyle = .none
backgroundColor = .clear backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([

View File

@ -0,0 +1,135 @@
//
// PickServerEmptyStateView.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/6.
//
import UIKit
final class PickServerEmptyStateView: UIView {
var topPaddingViewTopLayoutConstraint: NSLayoutConstraint!
let networkIndicatorImageView: UIImageView = {
let imageView = UIImageView()
let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular)
imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration)
imageView.tintColor = Asset.Colors.Label.secondary.color
return imageView
}()
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
let infoLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17)
label.textAlignment = .center
label.textColor = Asset.Colors.Label.secondary.color
label.text = "info"
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension PickServerEmptyStateView {
private func _init() {
backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
let topPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topPaddingView)
topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0)
NSLayoutConstraint.activate([
topPaddingViewTopLayoutConstraint,
topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.alignment = .center
containerStackView.distribution = .fill
containerStackView.spacing = 16
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
containerStackView.addArrangedSubview(networkIndicatorImageView)
let infoContainerView = UIView()
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
infoContainerView.addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor),
])
infoLabel.translatesAutoresizingMaskIntoConstraints = false
infoContainerView.addSubview(infoLabel)
NSLayoutConstraint.activate([
infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4),
infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor),
])
containerStackView.addArrangedSubview(infoContainerView)
let bottomPaddingView = UIView()
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomPaddingView)
NSLayoutConstraint.activate([
bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor),
bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
NSLayoutConstraint.activate([
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0),
])
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.startAnimating()
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct PickServerEmptyStateView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let emptyStateView = PickServerEmptyStateView()
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
emptyStateView.infoLabel.textAlignment = .center
emptyStateView.activityIndicatorView.stopAnimating()
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 400))
UIViewPreview(width: 375) {
let emptyStateView = PickServerEmptyStateView()
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left
emptyStateView.activityIndicatorView.startAnimating()
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 400))
}
}
}
#endif