I am writing an application to display news from the server. Created a StartController class that only displays data from the ViewModel. There are no warnings in the project, but at startup, a blank screen.
final class StartController: UIViewController {
// MARK: - Private properties
private var viewModel: ViewModel?
private let identifier = "tapeCell"
private let tableView = CustomTableView(frame: .zero)
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setUpView()
setUpConstraints()
reloadViewModel()
}
// MARK: - Private functions
private func reloadViewModel() {
viewModel?.configData { [weak self] in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
}
private func setUpView() {
view.backgroundColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
tableView.delegate = self
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: identifier)
view.addSubview(tableView)
}
}
// MARK: - Delegate
extension StartController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100
}
}
// MARK: - DataSource
extension StartController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel?.numberOfRowsInSection() ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath)
let VM = self.viewModel?.articleAtIndex(indexPath.row)
var content = cell.defaultContentConfiguration()
content.text = VM?.title
content.secondaryText = VM?.author
cell.contentConfiguration = content
return cell
}
}
All the logic takes place inside the ViewModel, downloading data from the network and transferring it to the StartController. But apparently, no data is being transmitted.
final class ViewModel {
// MARK: - Private properties
private let service = TapeService()
private var data: [Articles] = []
// MARK: - Functions
func configData(completion: #escaping() -> ()) {
service.tapeAdd { [weak self] result in
switch result {
case .success(let articles):
self?.data = articles
case .failure(let error):
print(error.localizedDescription)
}
}
}
func numberOfRowsInSection() -> Int {
return data.count
}
func articleAtIndex(_ index: Int) -> ArticleViewModel {
let article = self.data[index]
return ArticleViewModel(article)
}
}
// MARK: - ArticleViewModel
final class ArticleViewModel {
private let article: Articles
init(_ article: Articles) {
self.article = article
}
var title: String {
return self.article.title ?? " "
}
var author: String {
return self.article.author ?? " "
}
}
I would be very grateful if you could tell me what my mistake is and what needs to be done to display the data. Thanks!
Related
I have a UICollectionView placed inside a UITableViewCell. The collection view has its scroll direction set to horizontal. I have set the collection view and the collection view cell in the table view right, but when I run it the images don't show up and as I see that data doesn't pass right from table view cell to collection view cell. I cannot find anything wrong. I hope someone can find the mistake with a clearer mind.
Imagine that I want the first cell to have a collection view of images horizontally and in the other cells rows of names for example.
You can see my project on this GitHub account: https://github.com/BenSeferidis/Nft-Assets/tree/v5 for better understanding.
Lobby View Controller (Main VC):
The AssetTableViewCell is another custom cell that generates the rest of the data from my models: NFT API
import UIKit
class LobbyViewController: UIViewController {
// MARK: - IBProperties
#IBOutlet weak var tableView: UITableView!
// MARK: - Properties
var data: [DataEnum] = []
var likes:[Int] = []
var numlikes: Int = 0
var nfts: [Nft] = []
let creators : [Creator] = []
var icons: [Icon] = []
var loadData = APICaller()
// MARK: - Life Cyrcle
override func viewDidLoad() {
super.viewDidLoad()
let nib = UINib(nibName: "AssetTableViewCell", bundle: nil)
tableView.register(nib, forCellReuseIdentifier: "AssetTableViewCell")
let nib2 = UINib(nibName: "CreatorsTableViewCell", bundle: nil)
tableView.register(nib2, forCellReuseIdentifier: "CreatorsTableViewCell")
tableView.dataSource = self //method to generate cells,header and footer before they are displaying
tableView.delegate = self //method to provide information about these cells, header and footer ....
downloadJSON {
self.tableView.reloadData()
print("success")
}
loadData.downloadData { (result) in
print(result)
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let destination = segue.destination as? PresentViewController {
destination.nft = nfts[tableView.indexPathForSelectedRow!.row]
destination.delegate = self
}
}
// MARK: - Methods
func downloadJSON(completed: #escaping () -> ()) {
let url = URL(string: "https://public.arx.net/~chris2/nfts.json")
URLSession.shared.dataTask(with: url!) { [self] data, response, error in
if error == nil {
do {
self.nfts = try JSONDecoder().decode([Nft].self, from: data!)
let creators = nfts.map { nft in
nft.creator
}
self.data.append(.type1(creators: creators))
self.nfts.forEach { nft in
self.data.append(.type2(nft: nft))
}
DispatchQueue.main.async {
completed()
}
}
catch {
print("error fetching data from api")
}
}
}.resume()
}
}
// MARK: - Extensions
extension LobbyViewController : UITableViewDelegate , UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
indexPath.row == 0 ? 200 : UITableView.automaticDimension
}
//gemizo ta rows tou table
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch self.data[indexPath.item] {
case .type1(let creators):
print("--->", creators)
let cell = tableView.dequeueReusableCell(withIdentifier: "CreatorsTableViewCell",
for: indexPath) as! CreatorsTableViewCell
cell.updateCreators(creators)
return cell
case .type2(let nft):
let cell = tableView.dequeueReusableCell(withIdentifier: "AssetTableViewCell",
for: indexPath) as! AssetTableViewCell
cell.nameLabel?.text = nft.name
cell.nameLabel.layer.cornerRadius = cell.nameLabel.frame.height/2
cell.likesLabel?.text = "\((numlikes))"
let imgUrl = (nft.image_url)
print(imgUrl)
cell.iconView.downloaded(from: imgUrl)
cell.iconView.layer.cornerRadius = cell.iconView.frame.height/2
return cell
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
performSegue(withIdentifier: "showDetails", sender: self)
}
}
extension LobbyViewController : TestDelegate{
func sendBackTheLikess(int: Int) {
numlikes = int
tableView.reloadData()
}
}
// MARK: - Enums
enum DataEnum {
case type1(creators: [Creator])
case type2(nft: Nft)
}
// MARK: - Struct
struct Constants {
static let url = "https://public.arx.net/~chris2/nfts.json"
}
Creators TableView Cell :
import UIKit
class CreatorsTableViewCell: UITableViewCell {
//MARK: - IBProtperties
#IBOutlet var creatorsCollectionView: UICollectionView!
//MARK: - Properties
var nft : Nft?
var creators : [Creator] = []
weak var delegate : CreatorsTableViewCellDelegate?
//MARK: - Life Cyrcle
override func awakeFromNib() {
super.awakeFromNib()
creatorsCollectionView.dataSource = self
creatorsCollectionView.delegate = self
let nibName = UINib(nibName: "CollectionViewCell", bundle: nil)
creatorsCollectionView.register(nibName, forCellWithReuseIdentifier: "CollectionViewCell")
}
func updateCreators( _ creators: [Creator]) {
self.creators = creators
}
required init?(coder aDecoder : NSCoder) {
super.init(coder: aDecoder)
}
}
//MARK: - Extensions
extension CreatorsTableViewCell : UICollectionViewDelegate , UICollectionViewDataSource , UICollectionViewDelegateFlowLayout{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
CGSize(width: 30, height: 30)
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return creators.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = creatorsCollectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell",
for: indexPath) as! CollectionViewCell
cell.renewCreators(creators)
cell.creatorName.text = creators[indexPath.row].user.username
cell.creatorName.layer.cornerRadius = cell.creatorName.frame.height/2
cell.creatorsImg.image = UIImage(named: creators[indexPath.row].profileImgURL )
cell.creatorsImg.layer.cornerRadius = cell.creatorsImg.frame.height/2
return cell
// cell.backgroundColor = .brown
// cell.creatorName?.text = creators[indexPath.row].user.username
// let imgUrl = (creators[indexPath.row].profileImgURL)
// print(imgUrl)
// cell.creatorsImg.downloaded(from: imgUrl)
// return cell
}
}
//MARK: - Protocols
protocol CreatorsTableViewCellDelegate: AnyObject {
func didSelectPhoto(index: Int)
}
CollectionViewCell:
import UIKit
class CollectionViewCell: UICollectionViewCell {
//MARK: - IBProperties
#IBOutlet var creatorsImg: UIImageView!{
didSet {
creatorsImg.contentMode = .scaleAspectFit
}
}
#IBOutlet var creatorName: UILabel!
//MARK: - Properties
var nft : Nft?
var creators : [Creator] = []
//MARK: - Life Cyrcle
override func awakeFromNib() {
super.awakeFromNib()
print(creators)
creatorName.backgroundColor = .systemCyan
creatorsImg.layoutIfNeeded()
creatorsImg.layer.cornerRadius = creatorsImg.frame.height / 2
}
func setUpCollectionViewCell(_ nft: Nft) {
}
func renewCreators( _ creators: [Creator]) {
self.creators = creators
}
}
//MARK: - Protocols
protocol CollectionViewCellDelegate: AnyObject {
func didSelectPhoto(index: Int)
}
Models:
import Foundation
// MARK: - Nft
struct Nft: Codable{
let id:Int
let image_url:String
let name:String
let creator: Creator
}
// MARK: - Icon
struct Icon:Codable{
let image_url:String
}
// MARK: - Creator
struct Creator: Codable {
let user: User
let profileImgURL: String
enum CodingKeys: String, CodingKey {
case user
case profileImgURL = "profile_img_url"
}
}
// MARK: - User
struct User: Codable {
let username: String?
}
APICaller :
import Foundation
final class APICaller {
static let shared = APICaller()
public struct Constants {
static let url = "https://public.arx.net/~chris2/nfts.json"
}
public func downloadData(completion:#escaping (Result<[Nft], Error>) -> Void )
{
guard let url = URL(string:Constants.url)else{
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
//print(response)
print("here")
guard let data = data , error == nil else{
print("something went wrong")
return
}
print("here4")
//mexri edo exoume parei ta data kai tora me to do-catch tha ta kanoume convert se object
do{
//Decode the response
let nfts = try JSONDecoder().decode([Nft].self, from: data)
completion(.success(nfts))
print(nfts)
}catch{
completion(.failure(error))
}
}
task.resume()
}
}
Consider you are having the table view with a separate cell class that will be registered for table view later.
Now, we know how to disable the table view scroll using the table view instance, like in the below line.
tableView.isScrollEnabled = true/false
But what if I require to show some coach marks on the cell class, And I need to lock the table view scroll until that coach marks disappear using cell rather than table view. Because for a cell class table view instance is inaccessible since cell is within table view, not the table view within cell.
I've achieved this by using Notifications and Observers. But Please let me know if this can be achieved in any other way.
Is your target supporting iOS 13+? If so you can use Combine SDK. It will give you the same principle of notification and observers.
You will need a viewModel conforms to ObservableObject, then you will use #Published property wrapper, lets us easily construct types that emit signals whenever some of their properties were changed.
ViewModel.swift
enum ViewState {
case loading
case loaded
case showCoachMark
case hideCoachMark
}
class ViewModel: ObservableObject {
#Published var state: ViewState = .loading
.....
}
ViewController.swift
import Combine
import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
private let viewModel = ViewModel()
private var cancellable: AnyCancellable?
override func viewDidLoad(){
cancellable = viewModel.$state.sink { value in
// call tableview.isScrollEnabled = true/false
// Please push this back to the main thread if the state has
// been fetched via a request call
}
}
}
Here is simple trick that can answer your question:
class YourTableViewCell: UITableViewCell {
weak var tableView: UITableView? // declare a weak reference to your tableView that contains this cell
func disableScroll() {
tableView?.isScrollEnabled = false
}
func enableScroll() {
tableView?.isScrollEnabled = true
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: YourTableViewCell = ...
cell.tableView = tableView // assign this tableView to weak ref of tableView in YourTableViewCell
}
More isolation and loose-coupling:
class YourTableViewCell: UITableViewCell {
weak var onScrollEnabledChange: ((Bool) -> Void)?
func disableScroll() {
onScrollEnabledChange?(false)
}
func enableScroll() {
onScrollEnabledChange?(true)
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: YourTableViewCell = ...
cell.onScrollEnabledChange = { isEnabled in
// update above tableView's isScrollEnabled
tableView.isScrollEnabled = isEnabled
}
}
For a real solution:
In your BookViewModel.swift
import Foundation
import Combine
struct BookItemModel {
let name: String
var isDisplayingMark: Bool = true
}
enum DataLoadingState: Int {
case new, loading, finish
}
struct BookViewModelInput {
let loadData = PassthroughSubject<Void, Never>()
}
struct BookViewModelOutput {
let dataLoadingState = PassthroughSubject<DataLoadingState,Never>()
}
protocol IBookViewModel {
var input: BookViewModelInput { get }
var output: BookViewModelOutput { get }
func binding()
func clickCoachMark(index: Int)
func getItemCount() -> Int
func getItemAt(index: Int) -> BookItemModel
}
class BookViewModel: IBookViewModel {
private let _input = BookViewModelInput()
var input: BookViewModelInput { return self._input }
private let _output = BookViewModelOutput()
var output: BookViewModelOutput { return self._output }
private var cancellable = Set<AnyCancellable>()
private lazy var defaultQueue: DispatchQueue = {
let id = UUID().uuidString
let queue = DispatchQueue(label: "BookViewModel.\(id)", attributes: .concurrent)
return queue
}()
private var _books: [BookItemModel] = []
// MARK: - Function implementation
func binding() {
self._input.loadData
.receive(on: self.defaultQueue)
.sink {[unowned self] in
// this is event triggered from UI
// your screen own this BookViewModel, same life-cycle, so you can refer to self with unowned
// call your async data fetching
self.fetchData()
}.store(in: &self.cancellable)
}
func clickCoachMark(index: Int) {
self._books[index].isDisplayingMark = false
self._output.dataLoadingState.send(.finish) // trigger reloadData() again
}
// MARK: - Output handling
func getItemCount() -> Int {
return self._books.count
}
func getItemAt(index: Int) -> BookItemModel {
return self._books[index]
}
// MARK: - Input handling
private func fetchData() {
self._output.dataLoadingState.send(.loading)
// trigger block after 1 sec from now
self.defaultQueue.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
// Update data first
self?._books = Array(1...5).map({ BookItemModel(name: "\($0)") })
// then trigger new state
self?._output.dataLoadingState.send(.finish)
}
}
}
In your BookViewController.swift
import UIKit
import Combine
// I mostly name it as ABCScreen: UIViewController
class YourViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
private var cancellable = Set<AnyCancellable>()
var viewModel: IBookViewModel!
override func viewDidLoad() {
super.viewDidLoad()
self.setupListView()
// you can make a base class that trigger binding() on initialization then make BookViewModel become its subclass
self.viewModel.binding()
self.binding()
self.viewModel.input.loadData.send() // Void data type here, you can pass nothing as argument.
}
private func setupListView() {
self.tableView.register(UINib(nibName: "BookItemTableCell", bundle: nil), forCellReuseIdentifier: "BookItemTableCell")
self.tableView.dataSource = self
self.tableView.delegate = self
}
// here we are binding for output
private func binding() {
self.viewModel.output.dataLoadingState
.sink {[weak self] newState in
guard let _self = self else { return } // unwrapping optional self
// alway use weak self to refer to self in block that is triggered from viewModel
// because it can be async execution, different life-cycle with self (your screen)
// Perform some big data updating here
// This block is triggered from viewModel's defaultQueue
// it is not mainQueue to update UI
// Then switch to main queue to update UI,
DispatchQueue.main.async {
_self.tableView.isScrollEnabled = true // reset on each reloadData()
_self.tableView.reloadData() // refresh to update UI by new data
switch newState {
case .finish: _self.[hideLoadingIndicator]
case .loading: _self.[showLoadingIndicator]
case .new: break // do nothing, new is just initial-value
}
}
}.store(in: &self.cancellable)
}
}
extension YourViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.viewModel.getItemCount()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "BookItemTableCell", for: indexPath) as! BookItemTableCell
let item = self.viewModel.getItemAt(index: indexPath.row)
cell.setupUI(with: item)
cell.onClickCoachMark = { [unowned self] in
self.viewModel.clickCoachMark(index: indexPath.row)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 50
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let item = self.viewModel.getItemAt(index: indexPath.row)
// if there is atleast one book need to show coachMark -> disable scrolling
if item.isDisplayingMark {
tableView.isScrollEnabled = false
}
}
}
extension YourViewController {
// You will init YourViewController like below:
// let screen = YourViewController.build()
// navigationController.push(screen)
static func build() -> YourViewController {
let view = YourViewController()
view.viewModel = BookViewModel() // inject by setter
return view
}
}
In your BookItemTableCell.swift
import UIKit
class BookItemTableCell: UITableViewCell {
var onClickCoachMark: (() -> Void)?
override func awakeFromNib() {
super.awakeFromNib()
}
// Button click handler
func clickOnCoachMark() {
self.hideCoachMark()
self.onClickCoachMark?()
}
func setupUI(with item: BookItemModel) {
if item.isDisplayingMark {
self.showCoachMark()
} else {
self.hideCoachMark()
}
}
private func hideCoachMark() {}
private func showCoachMark() {}
}
I have a strange problem in a UIViewController that the user uses to search for a GIF.
There are essentially 2 issues:
The user has to enter the same search term twice before the UICollectionView triggers the cellForRowAt data source method after a call is made to reloadData().
After you enter the search term the first time, heightChanged() is called, but the self.GIFCollectionView.collectionViewLayout.collectionViewContentSize.height comes back as 0 even though I've confirmed that data is being received back from the server. The second time you enter the search term, the height is a non-zero value and the collection view shows the cells.
Here is an example of how I have to get the data to show up:
Launch app, go this UIViewController
Enter a search term (ie. "baseball")
Nothing shows up (even though reloadData() was called and new data is in the view model.)
Delete a character from the search term (ie. "basebal")
Type in the missing character (ie. "baseball")
The UICollectionView refreshes via a call to reloadData() and then calls cellForRowAt:.
Here is the entire View Controller:
import UIKit
protocol POGIFSelectViewControllerDelegate: AnyObject {
func collectionViewHeightDidChange(_ height: CGFloat)
func didSelectGIF(_ selectedGIFURL: POGIFURLs)
}
class POGIFSelectViewController: UIViewController {
//MARK: - Constants
private enum Constants {
static let POGIFCollectionViewCellIdentifier: String = "POGIFCollectionViewCell"
static let verticalPadding: CGFloat = 16
static let searchBarHeight: CGFloat = 40
static let searchLabelHeight: CGFloat = 24
static let activityIndicatorTopSpacing: CGFloat = 10
static let gifLoadDuration: Double = 0.2
static let gifStandardFPS: Double = 1/30
static let gifMaxDuration: Double = 5.0
}
//MARK: - Localized Strings
let localizedSearchGIFs = PALocalizedStringFromTable("RECOGNITION_IMAGE_SELECTION_GIF_BODY_TITLE", table: "Recognition-Osiris", comment: "Search GIFs") as String
//MARK: - Properties
var viewModel: POGIFSearchViewModel?
var activityIndicator = MDCActivityIndicator()
var gifLayout = PAGiphyCellLayout()
var selectedGIF: POGIFURLs?
//MARK: - IBOutlet
#IBOutlet weak var GIFCollectionView: UICollectionView!
#IBOutlet weak var searchGIFLabel: UILabel! {
didSet {
self.searchGIFLabel.text = self.localizedSearchGIFs
}
}
#IBOutlet weak var searchField: POSearchField! {
didSet {
self.searchField.delegate = self
}
}
#IBOutlet weak var activityIndicatorContainer: UIView!
//MARK: - Delegate
weak var delegate: POGIFSelectViewControllerDelegate?
//MARK: - View Lifecycle Methods
override func viewDidLoad() {
super.viewDidLoad()
self.setupActivityIndicator(activityIndicator: self.activityIndicator, activityIndicatorContainer: self.activityIndicatorContainer)
self.viewModel = POGIFSearchViewModel(data: PAData.sharedInstance())
if let viewModel = self.viewModel {
viewModel.viewDelegate = self
viewModel.viewDidBeginLoading()
}
self.gifLayout.delegate = self
self.gifLayout.isAXPGifLayout = true;
self.GIFCollectionView.collectionViewLayout = self.gifLayout
self.GIFCollectionView.backgroundColor = .orange
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// This Patch is to fix a bug where GIF contentSize was not calculated correctly on first load.
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1)) {
self.viewModel?.viewDidBeginLoading()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.heightChanged()
}
//MARK: - Helper Methods
func heightChanged() {
guard let delegate = self.delegate else { return }
let height = self.GIFCollectionView.collectionViewLayout.collectionViewContentSize.height + Constants.verticalPadding * 3 + Constants.searchLabelHeight + Constants.searchBarHeight + activityIndicatorContainer.frame.size.height + Constants.activityIndicatorTopSpacing
print("**** Items in Collection View -> self.viewModel?.gifModel.items.count: \(self.viewModel?.gifModel.items.count)")
print("**** self.GIFCollectionView.collectionViewLayout.collectionViewContentSize.height: \(self.GIFCollectionView.collectionViewLayout.collectionViewContentSize.height); height: \(height)")
delegate.collectionViewHeightDidChange(height)
}
func reloadCollectionView() {
self.GIFCollectionView.collectionViewLayout.invalidateLayout()
self.GIFCollectionView.reloadData()
self.GIFCollectionView.layoutIfNeeded()
self.heightChanged()
}
func imageAtIndexPath(_ indexPath: IndexPath) -> UIImage? {
guard let previewURL = self.viewModel?.gifModel.items[indexPath.row].previewGIFURL else { return nil }
var loadedImage: UIImage? = nil
let imageManager = SDWebImageManager.shared()
imageManager.loadImage(with: previewURL, options: .lowPriority, progress: nil) { (image: UIImage?, data: Data?, error: Error?, cacheType: SDImageCacheType, finished: Bool, imageURL: URL?) in
loadedImage = image
}
return loadedImage
}
func scrollViewDidScrollToBottom() {
guard let viewModel = self.viewModel else { return }
if viewModel.viewDidSearchMoreGIFs() {
self.activityIndicator.startAnimating()
} else {
self.activityIndicator.stopAnimating()
}
}
}
extension POGIFSelectViewController: POSearchFieldDelegate {
func searchFieldTextChanged(text: String?) {
guard let viewModel = self.viewModel else { return }
viewModel.viewDidSearchGIFs(withSearchTerm: text)
}
}
extension POGIFSelectViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
print("**** CELL FOR ROW AT -> self.viewModel?.gifModel.items.count: \(self.viewModel?.gifModel.items.count)")
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.POGIFCollectionViewCellIdentifier, for: indexPath) as! POGIFCollectionViewCell
guard let previewURL = self.viewModel?.gifModel.items[indexPath.row].previewGIFURL else {
return cell
}
var cellState: POGIFCollectionViewCell.CellState = .dimmedState
if self.selectedGIF == nil {
cellState = .defaultState
} else if (self.selectedGIF?.previewGIFURL?.absoluteString == previewURL.absoluteString) {
cellState = .selectedState
}
cell.setupUI(withState: cellState, URL: previewURL) { [weak self] () in
UIView.animate(withDuration: Constants.gifLoadDuration) {
guard let weakSelf = self else { return }
weakSelf.GIFCollectionView.collectionViewLayout.invalidateLayout()
}
}
if cell.GIFPreviewImageView.animationDuration > Constants.gifMaxDuration {
cell.GIFPreviewImageView.animationDuration = Constants.gifMaxDuration
}
cell.backgroundColor = .green
return cell
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
guard let viewModel = self.viewModel else { return 0 }
return viewModel.gifModel.items.count
}
}
extension POGIFSelectViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let selectedGIF = self.viewModel?.gifModel.items[indexPath.row],
let delegate = self.delegate else {
return
}
self.selectedGIF = selectedGIF
delegate.didSelectGIF(selectedGIF)
self.reloadCollectionView()
}
}
extension POGIFSelectViewController: POGIFSearchViewModelToViewProtocol {
func didFetchGIFsWithSuccess() {
self.activityIndicator.stopAnimating()
print("**** didFetchGIFsWithSuccess() -> about to reload collection view")
self.reloadCollectionView()
}
func didFetchGIFsWithError(_ error: Error!, request: PARequest!) {
self.activityIndicator.stopAnimating()
}
}
extension POGIFSelectViewController: PAGiphyLayoutCellDelegate {
func heightForCell(givenWidth cellWidth: CGFloat, at indexPath: IndexPath!) -> CGFloat {
guard let image = self.imageAtIndexPath(indexPath) else {
return 0
}
if (image.size.height < 1 || image.size.width < 1 || self.activityIndicator.isAnimating) {
return cellWidth
}
let scaleFactor = image.size.height / image.size.width
let imageViewToHighlightedViewSpacing: CGFloat = 4 // this number comes from 2 * highlightedViewBorderWidth from POGIFCollectionViewCell
return cellWidth * scaleFactor + imageViewToHighlightedViewSpacing
}
func heightForHeaderView() -> CGFloat {
return 0
}
}
You'll see that the heightChanged() method calls a delegate method. That method is in another UIViewController:
func collectionViewHeightDidChange(_ height: CGFloat) {
self.collectionViewHeightConstraint.constant = height
}
So, I can't figure out why I need to either delete a character from the search term and re-add it in order for the data to refresh even though the very first call populated the view model with new data.
It's bizarre. Please help.
I'm sort of new to iOS development using Swift. So, I might be missing a simple solution in Swift that I'm not aware of.
I am working on a tvOS app where I display a list of video content that the user can select from. The app also contains a settings tab that allows the user to configure 5 different types of settings. Once they select a specific category, it displays a new table view with the corresponding options which are in the options array. This is where the "issue" is that I need help.
I have this struct which I'm using as a singleton:
struct BMUserSettings
{
internal static var shared = BMUserSettings()
var categories = [String]()
var options = [[String]]()
var currOptionsSelected: [Int] = [0,0,0,0,0] // This array corresponds to the categories array. It tells us what option within that group was selected.
init()
{
self.categories = ["Brand", "Environment","UI Language", "Playback Language", "Geo Location Permission"]
let brandOptionsGroup: [String] = ["CTV", "CTVHUB", "TSN", "Snackable", "RDS", "CP24", "BNN", "CTVNews", "Crave", "BRAVO", "E_BRAND", "SE", "VIDIQA"]
let environmentOptionsGroup: [String] = ["Staging", "Prod"]
let uiLanguageOptionsGroup: [String] = ["en", "fr"]
let playbackLanguageOptionsGroup: [String] = ["en", "fr"]
let geoLocationOptionsGroup: [String] = ["Allow", "Don't Allow"]
options.append(brandOptionsGroup)
options.append(environmentOptionsGroup)
options.append(uiLanguageOptionsGroup)
options.append(playbackLanguageOptionsGroup)
options.append(geoLocationOptionsGroup)
}
// MARK: - Custom Methods
func displayUserSettings() -> String
{
let displayText: String = "Brand=\(options[0][currOptionsSelected[0]]) Environment=\(options[1][currOptionsSelected[1]]) UI Language=\(options[2][currOptionsSelected[2]]) Playback Language=\(options[3][currOptionsSelected[3]]) Geo Location=\(options[4][currOptionsSelected[4]])"
return displayText
}
// MARK: - User Defaults
func saveToUserDefaults()
{
UserDefaults.standard.set(BMUserSettings.shared.currOptionsSelected, forKey: "currentoptions")
}
func loadFromUserDefaults(){
if let currentOptionsSelected = UserDefaults.standard.object(forKey: "currentoptions") as? [Int]{
BMUserSettings.shared.currOptionsSelected = currentOptionsSelected
}
else{
BMUserSettings.shared.currOptionsSelected = [0,0,0,0,0]
}
}
}
As you can see, the "currOptionsSelected" integer array is holding the option that the user selected for each of the categories. For example, if the user chooses the brand "Snackable", then the first element of the currOptionsSelected array will hold a 3 as a value.
I'm saving and loading the currOptionsSelected to/from UserDefaults so that I know what the user's current settings are.
The problem with this approach is:
1) Even if I know the index of the specific option that the user chose, I will still need a set if if-else or switch conditions to make sure I can actually get the correct string value from the corresponding "options" array
2) If any other developer needs to add categories and corresponding options, then they need to make sure they keep everything in order
3) I just don't know if this is the best way of handling this type of issue
What is a better way of doing this?
Here's how I'm trying to use it in a table view:
import UIKit
final class BMSettingsViewController: UIViewController
{
// MARK: - Instance Variables
private static let reuseIdentifier = String(describing: BMContentCell.self)
private let tableview = UITableView(backgroundColor: .white, autoResizingMask: false)
private let tabBarBannerHeight: CGFloat = 150
private var selectedCategoryIndex: Int = 0
private var settingsDetailVC: BMDetailSettingsViewController?
private var categoryNames: [String] = [String]()
private var categoryOptions: [String] = [String]()
// MARK: - View Lifecycle Methods
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
categoryNames = BMCategory.allValues
categoryOptions = BMUserSettings.shared.currOptionsSelected.map { $0.value }
self.tableview.reloadData()
}
override func viewWillDisappear(_ animated: Bool)
{
BMUserSettings.shared.saveToUserDefaults()
}
override func loadView()
{
super.loadView()
self.tableview.dataSource = self
self.tableview.delegate = self
self.tableview.register(BMUserSettingsCell.self, forCellReuseIdentifier: BMSettingsViewController.reuseIdentifier)
displayContent()
}
// MARK: - Custom Methods
private func displayContent()
{
view.addSubview(tableview)
tableview.anchor(
top: self.view.topAnchor,
leading: self.view.leadingAnchor,
bottom: self.view.bottomAnchor,
trailing: self.view.trailingAnchor,
padding: UIEdgeInsets(top: tabBarBannerHeight, left: 0, bottom: 0, right: 0)
)
}
}
// MARK: - UITableView Datasource & Delegate Extension
extension BMSettingsViewController: UITableViewDataSource, UITableViewDelegate
{
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{
return "Select an option below to configure it's settings..."
}
func numberOfSections(in tableView: UITableView) -> Int
{
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
return categoryNames.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{
let cell = tableView.dequeueReusableCell(withIdentifier: BMSettingsViewController.reuseIdentifier, for: indexPath) as! BMUserSettingsCell
cell.configureCell(categoryName: categoryNames[indexPath.row], optionDetailDescription: categoryOptions[indexPath.row])
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
let selectedCategory = categoryNames[indexPath.row]
settingsDetailVC = BMDetailSettingsViewController()
guard let settingsVC = settingsDetailVC else {return}
settingsVC.options = BMUserSettings.shared.options[BMCategory.init(rawValue: selectedCategory)!]!
settingsVC.delegate = self
settingsVC.selectedCategoryIndex = indexPath.row
BMViewControllerManager.shared.getTopViewController()?.present(settingsVC, animated: true)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
{
return 120
}
}
// MARK: - Protocol Extension
extension BMSettingsViewController: OptionsSelector
{
func didFinishSelectingOption(selectedCategoryIndex: Int, selectedOptionIndex: Int)
{
self.selectedCategoryIndex = selectedCategoryIndex
// BMUserSettings.shared.currOptionsSelected[self.selectedCategoryIndex] = selectedOptionIndex
}
}
Here's the settings detail controller that lists just the options within that specific category:
import UIKit
// MARK: - Protocol (used to notify Settings view controller when an option was selected)
protocol OptionsSelector
{
func didFinishSelectingOption(selectedCategoryIndex: Int, selectedOptionIndex: Int)
}
final class BMDetailSettingsViewController: UIViewController
{
// MARK: - Instance Variables
private let cellId = "cellId"
private let tabBarBannerHeight: CGFloat = 150
private var selectedOptionIndex: Int = 0
private let tableview = UITableView(backgroundColor: .white, autoResizingMask: false)
var options: [String] = [String]()
var selectedCategoryIndex: Int = 0
var delegate: OptionsSelector?
// MARK: - View Life Cycle Methods
override func loadView()
{
super.loadView()
self.tableview.dataSource = self
self.tableview.delegate = self
self.tableview.register(UITableViewCell.self, forCellReuseIdentifier: cellId)
displayContent()
}
// MARK: - Custom Methods
private func displayContent()
{
view.addSubview(tableview)
tableview.anchor(
top: self.view.topAnchor,
leading: self.view.leadingAnchor,
bottom: self.view.bottomAnchor,
trailing: self.view.trailingAnchor,
padding: UIEdgeInsets(top: tabBarBannerHeight, left: 0, bottom: 0, right: 0)
)
}
}
// MARK: - UITableView Datasource & Delegate Extension
extension BMDetailSettingsViewController: UITableViewDataSource, UITableViewDelegate
{
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return "Select an option below..." }
func numberOfSections(in tableView: UITableView) -> Int { return 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return options.count }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = "\(options[indexPath.row])"
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
BMViewControllerManager.shared.getTopViewController()?.dismiss(animated: true)
self.selectedOptionIndex = indexPath.row
self.delegate?.didFinishSelectingOption(selectedCategoryIndex: self.selectedCategoryIndex, selectedOptionIndex: self.selectedOptionIndex)
}
}
Thank you!
First of all create an enum Category,
enum Category: String {
case brand = "Brand"
case environment = "Environment"
case uiLanguage = "UI Language"
case playbackLanguage = "Playback Language"
case geoLocationPermission = "Geo Location"
}
Next,
Create options of type [Category:[String]], currOptionsSelected of type [Category:String] and defaultOptions of type [Category:String].
Also, instead of displayUserSettings, conform struct BMUserSettings to CustomStringConvertible and implement the description to return the relevant String value.
And, to create the Singleton, mark init() as private.
There is no need to create a separate array for categories.
So the whole struct BMUserSettings will be like,
struct BMUserSettings: CustomStringConvertible {
static var shared = BMUserSettings()
let options: [Category:[String]]
let defaultOptions: [Category:String]
var currOptionsSelected: [Category:String]
let categories: [Category]
private init() {
options = [
.brand : ["CTV", "CTVHUB", "TSN", "Snackable", "RDS", "CP24", "BNN", "CTVNews", "Crave", "BRAVO", "E_BRAND", "SE", "VIDIQA"],
.environment : ["Staging", "Prod"],
.uiLanguage : ["en", "fr"],
.playbackLanguage : ["en", "fr"],
.geoLocationPermission : ["Allow", "Don't Allow"]
]
defaultOptions = self.options.mapValues{ $0.first! }
currOptionsSelected = self.defaultOptions
categories = [.brand, .environment, .uiLanguage, .playbackLanguage, .geoLocationPermission]
}
var description: String {
return self.currOptionsSelected.reduce("") { (result, option) -> String in
return "\(result) \(option.key.rawValue) = \(option.value)\n"
}
}
// MARK: - User Defaults
func saveToUserDefaults() {
var dict = [String:String]()
currOptionsSelected.forEach { dict[$0.key.rawValue] = $0.value }
UserDefaults.standard.set(dict, forKey: "currentoptions")
}
mutating func loadFromUserDefaults() {
if let currentOptionsSelected = UserDefaults.standard.object(forKey: "currentoptions") as? [String:String] {
var dict = [Category:String]()
currentOptionsSelected.forEach {
if let category = Category(rawValue: $0.key) {
dict[category] = $0.value
}
}
self.currOptionsSelected = dict
}
else {
self.currOptionsSelected = self.defaultOptions
}
}
}
Use it in the following way,
BMUserSettings.shared.currOptionsSelected[.brand] = "Snackable"
BMUserSettings.shared.saveToUserDefaults()
BMUserSettings.shared.loadFromUserDefaults()
print(BMUserSettings.shared)
BMUserSettings.shared.categories.forEach {
print($0.rawValue, ":", BMUserSettings.shared.currOptionsSelected[$0]!)
}
I have implemented pagination in UITableView with WillDisplay method. Pagination process is working fine but if I need to reload a list on button click, then data is appending in the list. How to work around with this ?
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if (indexPath.row + 1 == playlistViewModel.numberOfRowsInSection()) {
if playlistViewModel.isReload != false {
pageIncrement += 1
playlistViewModel.playListingApi(enterView: false, page: pageIncrement)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
pageIncrement = 1
playlistViewModel.playListingApi(enterView: true, page: pageIncrement)
}
playlistViewModel.hitNextApiClosure = { [weak self] () in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.playlistViewModel.isReload = false
self?.playlistViewModel.playlistArray?.removeAll()
self?.playlistTableView.reloadData()
self?.pageIncrement = 1
self?.playlistViewModel.playListingApi(enterView: true, page: self?.pageIncrement ?? 1)
}
}
And ViewModel method is
func playListingApi(enterView: Bool, page: Int) {
self.isLoading = true
if (enterView){
playlistArray?.removeAll()
isReload = false
}
playlistService.getPlayList(page: "\(page)", limit: "20") { (result) in
self.isLoading = false
switch result {
case .success(let data):
self.playlist = data as? Playlist
guard let data = self.playlist?.data?.blocks else {
self.errorMessage = AlertMessage.somethingWentWrong
return
}
for playlistData in data {
self.playlistArray?.append(playlistData)
self.isReload = true
}
if (data.count == 0){
self.isReload = false
}
self.reloadTableBool = true
case .error(let message):
self.isReload = false
self.errorMessage = message
}
}
}
When you are reloading your tableView set page = 1 , empty tableView data source and reload tableView. Finally hitAPI for fresh set of data .
page = 1
mTblDataSource.removeAll()
mTableView.reloadData()
hitAPI()
Consider this one as a possible solution.
public class Pageable<T> {
public enum ObjectState {
case loading
case loaded
}
public private (set) var page: Int = 0
private var items: [T] = [T]()
private var state: ObjectState = .loading
private let itemsPerPage: Int
private var itemsReloaded: (() -> ())
public init(itemsPerPage: Int, items: [T] = [], itemsReloaded: #escaping (() -> ())) {
self.items = items
self.itemsPerPage = itemsPerPage
self.itemsReloaded = itemsReloaded
}
public var itemsCount: Int {
switch state {
case .loaded:
return items.count
case .loading:
return items.count + 1 // should be displaying cell with loading indicator
}
}
public var isLoaded: Bool {
return state == .loaded
}
public var isLoading: Bool {
return state == .loading
}
public func append(contentsOf items: [T]) {
state = items.count < itemsPerPage ? .loaded : .loading
self.items.append(contentsOf: items)
itemsReloaded()
}
public func incrementPage() {
page += 1
}
public func reset() {
page = 0
state = .loading
items = []
}
public func itemFor(_ index: Int) -> T? {
return items.indices.contains(index) ? items[index] : nil
}
}
struct Property {}
protocol SearchItemsDisplayLogic: class {
func reloadItemsViews()
}
protocol SearchItemsInteraction {
func loadMore(page: Int)
}
// MARK: View Related with UITableView example
lazy var refreshControl: UIRefreshControl = {
let refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action: #selector(pullToRefresh(_:)), for: UIControl.Event.valueChanged)
return refreshControl
}()
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.itemsCount
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
presenter.viewWillDisplayCellAt(indexPath.row)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if presenter.isLoadingCellNeeded(indexPath.row) {
return tableView.dequeueReusableCell(withIdentifier: "\(LoadingTableViewCell.self)", for: indexPath)
}
let cell = tableView.dequeueReusableCell(withIdentifier: "\(PropertyTableViewCell.self)", for: indexPath) as? PropertyTableViewCell
presenter.populate(cell: cell, indexPath: indexPath)
return cell ?? UITableViewCell(style: .default, reuseIdentifier: "\(UITableViewCell.self)")
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let property = presenter.property(indexPath.row) else {
return
}
}
protocol SearchItemsPresentation {
// MARK: Pagination logic
var itemsCount: Int { get }
// From the view.
func isLoadingCellNeeded(_ item: Int) -> Bool
func viewWillDisplayCellAt(_ item: Int)
func pullToRefresh()
func property(_ item: Int) -> Property?
// From the interactor.
func presentItems(items: [Property])
}
// MARK: - Presenter
class SearchItemsPresenter: SearchItemsPresentation {
weak var propertyDisplay: SearchItemsDisplayLogic?
lazy var interactor: SearchItemsInteraction? = {
return SearchItemsInteractor(presenter: self)
}()
var itemsCount: Int {
return pageable.itemsCount
}
private var pageable: Pageable<Property>!
init(viewController: SearchItemsDisplayLogic) {
self.propertyDisplay = viewController
pageable = Pageable(itemsPerPage: 15, itemsReloaded: {
self.propertyDisplay?.reloadItemsViews()
})
}
// TODO: presenter should not have UIKit!
func populate(cell: CellProtocol?, indexPath: IndexPath) {
guard let cell = cell else { return }
// populate
}
}
extension SearchItemsPresenter {
func property(_ index: Int) -> Property? {
return pageable.itemFor(index)
}
}
// MARK: Pageable
extension SearchItemsPresenter {
/// if it's loading show loading cell in the view.
func isLoadingCellNeeded(_ item: Int) -> Bool {
let isViewAtTheBottom = item == itemsCount - 1
return isViewAtTheBottom && pageable.isLoading
}
/// Called in `willDisplay` methods of the view.
func viewWillDisplayCellAt(_ item: Int) {
let isViewAtTheBottom = item == itemsCount - 1
if isViewAtTheBottom && pageable.isLoading {
interactor?.loadMore(page: pageable.page)
pageable.incrementPage()
}
}
func pullToRefresh() {
pageable.reset()
interactor?.loadMore(page: pageable.page)
pageable.incrementPage()
}
func presentItems(items: [Property]) {
pageable.append(contentsOf: items)
}
}
// MARK: - Interactor
class SearchItemsInteractor: SearchItemsInteraction {
private var presenter: SearchItemsPresentation
init(presenter: SearchItemsPresentation) {
self.presenter = presenter
}
func loadMore(page: Int) {
DispatchQueue.global(qos: .background).async {
sleep(1)
DispatchQueue.main.async {
// TODO: return some data
self.presenter.presentItems(items: [])
}
}
}
}