[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"
|
"version" : "1.0"
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// FeedListState.swift
|
||||||
|
// ReviewsFeed
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 22/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
enum FeedListState {
|
||||||
|
case empty
|
||||||
|
case error
|
||||||
|
case initial
|
||||||
|
case populated
|
||||||
|
}
|
@ -24,6 +24,7 @@ extension FeedListViewController {
|
|||||||
@Published var isFilterEnabled: Bool = false
|
@Published var isFilterEnabled: Bool = false
|
||||||
@Published var isFiltering: Bool = false
|
@Published var isFiltering: Bool = false
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var state: FeedListState = .initial
|
||||||
|
|
||||||
var items: [Review] = []
|
var items: [Review] = []
|
||||||
var words: [TopWord] = []
|
var words: [TopWord] = []
|
||||||
@ -88,11 +89,14 @@ extension FeedListViewController {
|
|||||||
items = filter == .all
|
items = filter == .all
|
||||||
? reviewsAll
|
? reviewsAll
|
||||||
: reviewsFiltered[filter] ?? []
|
: reviewsFiltered[filter] ?? []
|
||||||
|
|
||||||
isFilterEnabled = !items.isEmpty
|
isFilterEnabled = !items.isEmpty
|
||||||
|
state = items.isEmpty
|
||||||
|
? .empty
|
||||||
|
: .populated
|
||||||
} catch {
|
} catch {
|
||||||
// TODO: handle this error gracefully.
|
items = []
|
||||||
print("ERROR: \(error.localizedDescription)")
|
state = .error
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@ -111,7 +115,12 @@ extension FeedListViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func item(for index: Int) -> Review? {
|
func item(for index: Int) -> Review? {
|
||||||
guard index < items.count else { return nil }
|
guard
|
||||||
|
!items.isEmpty,
|
||||||
|
index < items.count
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return isWordsShowing
|
return isWordsShowing
|
||||||
? items[index - 1]
|
? items[index - 1]
|
||||||
|
@ -13,12 +13,14 @@ import ReviewsUIKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class FeedListViewController: UITableViewController {
|
final class FeedListViewController: UIViewController {
|
||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
private let viewModel: ViewModel
|
private let viewModel: ViewModel
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
private var unavailableView: FeedUnavailableView?
|
||||||
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
// MARK: Outlets
|
// MARK: Outlets
|
||||||
@ -26,10 +28,10 @@ final class FeedListViewController: UITableViewController {
|
|||||||
let indicator = UIActivityIndicatorView()
|
let indicator = UIActivityIndicatorView()
|
||||||
|
|
||||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
return indicator
|
return indicator
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var filterButton = {
|
private lazy var filterButton = {
|
||||||
UIBarButtonItem(
|
UIBarButtonItem(
|
||||||
title: NSLocalizedString(
|
title: NSLocalizedString(
|
||||||
@ -46,29 +48,27 @@ final class FeedListViewController: UITableViewController {
|
|||||||
comment: .empty
|
comment: .empty
|
||||||
),
|
),
|
||||||
image: UIImage.Icon.star,
|
image: UIImage.Icon.star,
|
||||||
children: {
|
children: FilterOption.allCases.map { option -> UIAction in
|
||||||
FilterOption.allCases.map { option -> UIAction in
|
|
||||||
.init(
|
.init(
|
||||||
title: option.text,
|
title: option.text,
|
||||||
image: .init(systemName: option.icon)
|
image: .init(systemName: option.icon)
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.viewModel.filter(by: option)
|
self?.viewModel.filter(by: option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var pullControl = {
|
private lazy var pullControl = {
|
||||||
let control = UIRefreshControl()
|
let control = UIRefreshControl()
|
||||||
|
|
||||||
control.attributedTitle = .init(string: NSLocalizedString(
|
control.attributedTitle = .init(string: NSLocalizedString(
|
||||||
.Key.PullToRefresh.title,
|
.Key.PullToRefresh.title,
|
||||||
bundle: .module,
|
bundle: .module,
|
||||||
comment: .empty
|
comment: .empty
|
||||||
))
|
))
|
||||||
|
|
||||||
control.addTarget(
|
control.addTarget(
|
||||||
self,
|
self,
|
||||||
action: #selector(refresh(_:)),
|
action: #selector(refresh(_:)),
|
||||||
@ -77,12 +77,26 @@ final class FeedListViewController: UITableViewController {
|
|||||||
|
|
||||||
return control
|
return control
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private lazy var tableView = {
|
||||||
|
let table = UITableView(
|
||||||
|
frame: .zero,
|
||||||
|
style: .plain
|
||||||
|
)
|
||||||
|
|
||||||
|
table.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
return table
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: Initialisers
|
// MARK: Initialisers
|
||||||
init(_ viewModel: ViewModel) {
|
init(_ viewModel: ViewModel) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
|
|
||||||
super.init(style: .plain)
|
super.init(
|
||||||
|
nibName: nil,
|
||||||
|
bundle: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@ -93,11 +107,11 @@ final class FeedListViewController: UITableViewController {
|
|||||||
var items: [Review] {
|
var items: [Review] {
|
||||||
viewModel.items
|
viewModel.items
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UIViewController
|
// MARK: UIViewController
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
setNavigationBar()
|
setNavigationBar()
|
||||||
setView()
|
setView()
|
||||||
setLayout()
|
setLayout()
|
||||||
@ -107,15 +121,20 @@ final class FeedListViewController: UITableViewController {
|
|||||||
viewModel.fetch()
|
viewModel.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UITableViewDataSource
|
}
|
||||||
override func tableView(
|
|
||||||
|
// MARK: - UITableViewDataSource
|
||||||
|
extension FeedListViewController: UITableViewDataSource {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
numberOfRowsInSection section: Int
|
numberOfRowsInSection section: Int
|
||||||
) -> Int {
|
) -> Int {
|
||||||
viewModel.itemsCount
|
viewModel.itemsCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(
|
func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
) -> UITableViewCell {
|
) -> 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,
|
_ tableView: UITableView,
|
||||||
didSelectRowAt indexPath: IndexPath
|
didSelectRowAt indexPath: IndexPath
|
||||||
) {
|
) {
|
||||||
@ -183,13 +207,78 @@ private extension FeedListViewController {
|
|||||||
self?.activityIndicator.stopAnimating()
|
self?.activityIndicator.stopAnimating()
|
||||||
self?.pullControl.endRefreshing()
|
self?.pullControl.endRefreshing()
|
||||||
self?.tableView.reloadData()
|
self?.tableView.reloadData()
|
||||||
self?.tableView.scrollToRow(
|
|
||||||
at: .init(row: 0, section: 0),
|
if self?.viewModel.state == .populated {
|
||||||
at: .middle,
|
self?.tableView.scrollToRow(
|
||||||
animated: true
|
at: .init(row: 0, section: 0),
|
||||||
)
|
at: .middle,
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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(
|
func makeFeedItemCell(
|
||||||
@ -245,10 +334,22 @@ private extension FeedListViewController {
|
|||||||
TopWordsCell.register(in: tableView)
|
TopWordsCell.register(in: tableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeUnavailableView() {
|
||||||
|
guard let unavailableView else { return }
|
||||||
|
|
||||||
|
unavailableView.view.removeFromSuperview()
|
||||||
|
|
||||||
|
self.unavailableView = nil
|
||||||
|
}
|
||||||
|
|
||||||
func setLayout() {
|
func setLayout() {
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
activityIndicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
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() {
|
func setView() {
|
||||||
tableView.refreshControl = pullControl
|
view.addSubview(tableView)
|
||||||
|
|
||||||
view.addSubview(activityIndicator)
|
view.addSubview(activityIndicator)
|
||||||
view.bringSubviewToFront(activityIndicator)
|
view.bringSubviewToFront(activityIndicator)
|
||||||
|
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.refreshControl = pullControl
|
||||||
|
tableView.dataSource = self
|
||||||
|
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,6 +413,22 @@ private extension String {
|
|||||||
enum PullToRefresh {
|
enum PullToRefresh {
|
||||||
static let title = "view.feed-list.pull-to-refresh.title.text"
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,15 +451,6 @@ private extension UIEdgeInsets {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
import ReviewsiTunesKit
|
import ReviewsiTunesKit
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
#Preview("Feed List loading reviews") {
|
|
||||||
MockURLProtocol.response = .init(statusCode: 200)
|
|
||||||
|
|
||||||
return UINavigationController(rootViewController: FeedListViewController(.init(
|
|
||||||
configuration: .init(session: .mock)
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
@available(iOS 17.0, *)
|
||||||
#Preview("Feed List with few reviews") {
|
#Preview("Feed List with few reviews") {
|
||||||
MockURLProtocol.response = .init(
|
MockURLProtocol.response = .init(
|
||||||
@ -410,6 +521,15 @@ import ReviewsiTunesKit
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 17.0, *)
|
||||||
|
#Preview("Feed List with error when loading reviews") {
|
||||||
|
MockURLProtocol.response = .init(statusCode: 404)
|
||||||
|
|
||||||
|
return UINavigationController(rootViewController: FeedListViewController(.init(
|
||||||
|
configuration: .init(session: .mock)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
@available(iOS 17.0, *)
|
||||||
#Preview("Feed List with live reviews") {
|
#Preview("Feed List with live reviews") {
|
||||||
return UINavigationController(rootViewController: FeedListViewController(.init()))
|
return UINavigationController(rootViewController: FeedListViewController(.init()))
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// FeedUnavailableView.swift
|
||||||
|
// Feed
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 22/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ReviewsUIKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
final class FeedUnavailableView: UIHostingController<UnavailableView> {
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
convenience init(
|
||||||
|
systemImage: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
button: String? = nil,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.init(rootView: .init(
|
||||||
|
systemImage: systemImage,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
button: button,
|
||||||
|
action: action
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
|
||||||
|
if view.safeAreaInsets.bottom > 0 {
|
||||||
|
additionalSafeAreaInsets = .init(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: -view.safeAreaInsets.bottom,
|
||||||
|
right: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -10,6 +10,8 @@ public extension String {
|
|||||||
enum Icon {
|
enum Icon {
|
||||||
|
|
||||||
// MARK: Constants
|
// 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 info = "info.circle.fill"
|
||||||
public static let person = "person.crop.circle.fill"
|
public static let person = "person.crop.circle.fill"
|
||||||
public static let starAll = "a.circle"
|
public static let starAll = "a.circle"
|
||||||
|
105
Libraries/UI/Kit/Sources/Views/UnavailableView.swift
Normal file
105
Libraries/UI/Kit/Sources/Views/UnavailableView.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// UnavailableView.swift
|
||||||
|
// ReviewsUIKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 22/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct UnavailableView: View {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
private let description: String
|
||||||
|
private let systemImage: String
|
||||||
|
private let title: String
|
||||||
|
|
||||||
|
private let action: (() -> Void)?
|
||||||
|
private let button: String?
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
public init(
|
||||||
|
systemImage: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
button: String? = nil,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.action = action
|
||||||
|
self.button = button
|
||||||
|
self.description = description
|
||||||
|
self.systemImage = systemImage
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Body
|
||||||
|
public var body: some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
if let button, let action {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
} description: {
|
||||||
|
Text(description)
|
||||||
|
} actions: {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
title,
|
||||||
|
systemImage: systemImage,
|
||||||
|
description: Text(description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let button, let action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(button)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
#Preview("Unavailable View with image, title, and description") {
|
||||||
|
UnavailableView(
|
||||||
|
systemImage: "star",
|
||||||
|
title: "Some title goes here...",
|
||||||
|
description: "Some long, explanatory description goes in here..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Unavailable View with image, title, description, button, and action") {
|
||||||
|
UnavailableView(
|
||||||
|
systemImage: "star",
|
||||||
|
title: "Some title goes here...",
|
||||||
|
description: "Some long, explanatory description goes in here...",
|
||||||
|
button: "Some button text here..."
|
||||||
|
) {
|
||||||
|
// On action closure.
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,8 @@
|
|||||||
028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */; };
|
028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */; };
|
||||||
028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134812BACCC770074AB4B /* FeedListCoordination.swift */; };
|
028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134812BACCC770074AB4B /* FeedListCoordination.swift */; };
|
||||||
028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */; };
|
028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */; };
|
||||||
|
028134882BAD01A60074AB4B /* FeedListState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134872BAD01A60074AB4B /* FeedListState.swift */; };
|
||||||
|
0281348A2BAD08AB0074AB4B /* FeedUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */; };
|
||||||
02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E782BAB6B0200710E14 /* FilterOption.swift */; };
|
02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E782BAB6B0200710E14 /* FilterOption.swift */; };
|
||||||
02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */; };
|
02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */; };
|
||||||
02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */; };
|
02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */; };
|
||||||
@ -68,6 +70,8 @@
|
|||||||
028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListConfiguration.swift; sourceTree = "<group>"; };
|
028134702BACC8CC0074AB4B /* FeedListConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListConfiguration.swift; sourceTree = "<group>"; };
|
||||||
028134812BACCC770074AB4B /* FeedListCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordination.swift; sourceTree = "<group>"; };
|
028134812BACCC770074AB4B /* FeedListCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordination.swift; sourceTree = "<group>"; };
|
||||||
028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCoordinator.swift; sourceTree = "<group>"; };
|
028134832BACD0B20074AB4B /* FeedItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
028134872BAD01A60074AB4B /* FeedListState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListState.swift; sourceTree = "<group>"; };
|
||||||
|
028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUnavailableView.swift; sourceTree = "<group>"; };
|
||||||
02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = "<group>"; };
|
02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = "<group>"; };
|
||||||
02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Constants.swift"; sourceTree = "<group>"; };
|
02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Constants.swift"; sourceTree = "<group>"; };
|
||||||
02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review+DTOs.swift"; sourceTree = "<group>"; };
|
02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review+DTOs.swift"; sourceTree = "<group>"; };
|
||||||
@ -168,6 +172,7 @@
|
|||||||
children = (
|
children = (
|
||||||
0220ADA22BA90646001E6A9F /* FeedItemView.swift */,
|
0220ADA22BA90646001E6A9F /* FeedItemView.swift */,
|
||||||
02EACF312BABB23A00FF8ECD /* TopWordsView.swift */,
|
02EACF312BABB23A00FF8ECD /* TopWordsView.swift */,
|
||||||
|
028134892BAD08AB0074AB4B /* FeedUnavailableView.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -200,6 +205,7 @@
|
|||||||
02909E772BAB6AD500710E14 /* Enumerations */ = {
|
02909E772BAB6AD500710E14 /* Enumerations */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
028134872BAD01A60074AB4B /* FeedListState.swift */,
|
||||||
02909E782BAB6B0200710E14 /* FilterOption.swift */,
|
02909E782BAB6B0200710E14 /* FilterOption.swift */,
|
||||||
);
|
);
|
||||||
path = Enumerations;
|
path = Enumerations;
|
||||||
@ -498,6 +504,7 @@
|
|||||||
02DC7FAC2BA51B4C000EEEBE /* FeedItemViewController.swift in Sources */,
|
02DC7FAC2BA51B4C000EEEBE /* FeedItemViewController.swift in Sources */,
|
||||||
02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */,
|
02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */,
|
||||||
02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */,
|
02EACF2E2BABA34600FF8ECD /* FeedItemCell.swift in Sources */,
|
||||||
|
0281348A2BAD08AB0074AB4B /* FeedUnavailableView.swift in Sources */,
|
||||||
028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */,
|
028134842BACD0B20074AB4B /* FeedItemCoordinator.swift in Sources */,
|
||||||
02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */,
|
02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */,
|
||||||
0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */,
|
0220ADA32BA90646001E6A9F /* FeedItemView.swift in Sources */,
|
||||||
@ -508,6 +515,7 @@
|
|||||||
02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */,
|
02EACF322BABB23A00FF8ECD /* TopWordsView.swift in Sources */,
|
||||||
028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */,
|
028134712BACC8CC0074AB4B /* FeedListConfiguration.swift in Sources */,
|
||||||
028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */,
|
028134822BACCC780074AB4B /* FeedListCoordination.swift in Sources */,
|
||||||
|
028134882BAD01A60074AB4B /* FeedListState.swift in Sources */,
|
||||||
02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */,
|
02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user