[Framework] Empty and error state handling for the Feed list #18
@ -120,6 +120,66 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.unavailable.empty.description.text" : {
|
||||
"comment" : "The text for the description of the Unavailable component in the Feed List view when no items are available",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No reviews matching the criteria have been found.\nPlease try fetching them at a later time."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.unavailable.empty.title.text" : {
|
||||
"comment" : "The text for the title of the Unavailable component in the Feed List view when no items are available",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No reviews available"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.unavailable.error.button.try-again.text" : {
|
||||
"comment" : "The text for the Try Again button of the Unavailable component in the Feed List view when an error occurred",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Try fetching data again"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.unavailable.error.description.text" : {
|
||||
"comment" : "The text for the title of the Unavailable component in the Feed List view when an error occurred",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "An error occurred while trying to fetch the reviews.\nPlease try this operation them at a later time."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.feed-list.unavailable.error.title.text" : {
|
||||
"comment" : "The text for the title of the Unavailable component in the Feed List view when an error occurred",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Something went wrong"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
@ -13,12 +13,14 @@ import ReviewsUIKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class FeedListViewController: UITableViewController {
|
||||
|
||||
final class FeedListViewController: UIViewController {
|
||||
|
||||
// MARK: Constants
|
||||
private let viewModel: ViewModel
|
||||
|
||||
// MARK: Properties
|
||||
private var unavailableView: FeedUnavailableView?
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// MARK: Outlets
|
||||
@ -26,10 +28,10 @@ final class FeedListViewController: UITableViewController {
|
||||
let indicator = UIActivityIndicatorView()
|
||||
|
||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
|
||||
return indicator
|
||||
}()
|
||||
|
||||
|
||||
private lazy var filterButton = {
|
||||
UIBarButtonItem(
|
||||
title: NSLocalizedString(
|
||||
@ -46,29 +48,27 @@ final class FeedListViewController: UITableViewController {
|
||||
comment: .empty
|
||||
),
|
||||
image: UIImage.Icon.star,
|
||||
children: {
|
||||
FilterOption.allCases.map { option -> UIAction in
|
||||
children: FilterOption.allCases.map { option -> UIAction in
|
||||
.init(
|
||||
title: option.text,
|
||||
image: .init(systemName: option.icon)
|
||||
) { [weak self] _ in
|
||||
self?.viewModel.filter(by: option)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
private lazy var pullControl = {
|
||||
let control = UIRefreshControl()
|
||||
|
||||
|
||||
control.attributedTitle = .init(string: NSLocalizedString(
|
||||
.Key.PullToRefresh.title,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
))
|
||||
|
||||
|
||||
control.addTarget(
|
||||
self,
|
||||
action: #selector(refresh(_:)),
|
||||
@ -77,12 +77,26 @@ final class FeedListViewController: UITableViewController {
|
||||
|
||||
return control
|
||||
}()
|
||||
|
||||
private lazy var tableView = {
|
||||
let table = UITableView(
|
||||
frame: .zero,
|
||||
style: .plain
|
||||
)
|
||||
|
||||
table.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return table
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
init(_ viewModel: ViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init(style: .plain)
|
||||
|
||||
super.init(
|
||||
nibName: nil,
|
||||
bundle: nil
|
||||
)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@ -93,11 +107,11 @@ final class FeedListViewController: UITableViewController {
|
||||
var items: [Review] {
|
||||
viewModel.items
|
||||
}
|
||||
|
||||
|
||||
// MARK: UIViewController
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
||||
setNavigationBar()
|
||||
setView()
|
||||
setLayout()
|
||||
@ -107,15 +121,20 @@ final class FeedListViewController: UITableViewController {
|
||||
viewModel.fetch()
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
override func tableView(
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
extension FeedListViewController: UITableViewDataSource {
|
||||
|
||||
// MARK: Functions
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
viewModel.itemsCount
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
@ -126,8 +145,13 @@ final class FeedListViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
override func tableView(
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension FeedListViewController: UITableViewDelegate {
|
||||
|
||||
// MARK: Functions
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
didSelectRowAt indexPath: IndexPath
|
||||
) {
|
||||
@ -183,13 +207,78 @@ private extension FeedListViewController {
|
||||
self?.activityIndicator.stopAnimating()
|
||||
self?.pullControl.endRefreshing()
|
||||
self?.tableView.reloadData()
|
||||
self?.tableView.scrollToRow(
|
||||
at: .init(row: 0, section: 0),
|
||||
at: .middle,
|
||||
animated: true
|
||||
)
|
||||
|
||||
if self?.viewModel.state == .populated {
|
||||
self?.tableView.scrollToRow(
|
||||
at: .init(row: 0, section: 0),
|
||||
at: .middle,
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$state
|
||||
.dropFirst()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] state in
|
||||
switch state {
|
||||
case .empty,
|
||||
.error:
|
||||
self?.insertUnavailableView(state)
|
||||
default:
|
||||
self?.removeUnavailableView()
|
||||
}
|
||||
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func insertUnavailableView(_ state: FeedListState) {
|
||||
guard unavailableView == nil else { return }
|
||||
|
||||
let isErrorState = state == .error
|
||||
let unavailable = FeedUnavailableView(
|
||||
systemImage: isErrorState
|
||||
? .Icon.error
|
||||
: .Icon.empty,
|
||||
title: NSLocalizedString(
|
||||
isErrorState
|
||||
? .Key.Unavailable.Title.error
|
||||
: .Key.Unavailable.Title.empty,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
),
|
||||
description: NSLocalizedString(
|
||||
isErrorState
|
||||
? .Key.Unavailable.Description.error
|
||||
: .Key.Unavailable.Description.empty,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
),
|
||||
button: isErrorState
|
||||
? NSLocalizedString(
|
||||
.Key.Unavailable.Button.tryAgain,
|
||||
bundle: .module,
|
||||
comment: .empty
|
||||
)
|
||||
: nil,
|
||||
action: isErrorState
|
||||
? { self.viewModel.fetch() }
|
||||
: nil
|
||||
)
|
||||
|
||||
addChild(unavailable)
|
||||
|
||||
view.addSubview(unavailable.view)
|
||||
view.bringSubviewToFront(unavailable.view)
|
||||
|
||||
// TODO: fix [this issue](https://stackoverflow.com/questions/70032739/why-does-swiftui-uihostingcontroller-have-extra-spacing) so autolayout can be used instead.
|
||||
unavailable.view.frame = tableView.bounds
|
||||
|
||||
unavailable.didMove(toParent: self)
|
||||
|
||||
unavailableView = unavailable
|
||||
}
|
||||
|
||||
func makeFeedItemCell(
|
||||
@ -245,10 +334,22 @@ private extension FeedListViewController {
|
||||
TopWordsCell.register(in: tableView)
|
||||
}
|
||||
|
||||
func removeUnavailableView() {
|
||||
guard let unavailableView else { return }
|
||||
|
||||
unavailableView.view.removeFromSuperview()
|
||||
|
||||
self.unavailableView = nil
|
||||
}
|
||||
|
||||
func setLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
activityIndicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@ -265,11 +366,14 @@ private extension FeedListViewController {
|
||||
}
|
||||
|
||||
func setView() {
|
||||
tableView.refreshControl = pullControl
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(activityIndicator)
|
||||
view.bringSubviewToFront(activityIndicator)
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.refreshControl = pullControl
|
||||
tableView.dataSource = self
|
||||
|
||||
activityIndicator.startAnimating()
|
||||
}
|
||||
|
||||
@ -309,6 +413,22 @@ private extension String {
|
||||
enum PullToRefresh {
|
||||
static let title = "view.feed-list.pull-to-refresh.title.text"
|
||||
}
|
||||
|
||||
enum Unavailable {
|
||||
enum Button {
|
||||
static let tryAgain = "view.feed-list.unavailable.error.button.try-again.text"
|
||||
}
|
||||
|
||||
enum Description {
|
||||
static let empty = "view.feed-list.unavailable.empty.description.text"
|
||||
static let error = "view.feed-list.unavailable.empty.description.text"
|
||||
}
|
||||
|
||||
enum Title {
|
||||
static let empty = "view.feed-list.unavailable.empty.title.text"
|
||||
static let error = "view.feed-list.unavailable.error.title.text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,7 +453,7 @@ import ReviewsiTunesKit
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
#Preview("Feed List loading reviews") {
|
||||
MockURLProtocol.response = .init(statusCode: 200)
|
||||
MockURLProtocol.response = .init(statusCode: 404)
|
||||
|
||||
return UINavigationController(rootViewController: FeedListViewController(.init(
|
||||
configuration: .init(session: .mock)
|
||||
|
@ -10,6 +10,8 @@ public extension String {
|
||||
enum Icon {
|
||||
|
||||
// MARK: Constants
|
||||
public static let empty = "list.star"
|
||||
public static let error = "exclamationmark.triangle.fill"
|
||||
public static let info = "info.circle.fill"
|
||||
public static let person = "person.crop.circle.fill"
|
||||
public static let starAll = "a.circle"
|
||||
|
Loading…
x
Reference in New Issue
Block a user