I'm using UICollectionViewCompositionalLayout
with UICollectionViewCell
class. Also I have a DownloadManager
class. I want to download a file after clicking a cell. I'm using didSelectItemAt
method to start downloading. And I'm using progressView
and title
in UICollectionViewCell
class to show the progress state when downloading starts. But if I scroll my collection view when my file is in the process of downloading my cell downloading progress will jump over and show up in another cell that does not downloading. In other words, the progressView
shows progress in different cells, but I'm only downloading one file. How to fix it?
enum DownloadStatus {
case none
case inProgress
case completed
case failed
}
struct item {
var number: Int!
var downloadStatus: DownloadStatus = .none
init(number: Int) { self.number = number }
}
var downloadQueue = [Int: [Int]]()
var masterIndex = 0
extension URLSession {
func getSessionDescription () -> Int { return Int(self.sessionDescription!)! } // Item ID
func getDebugDescription () -> Int { return Int(self.debugDescription)! } // Collection ID
}
DownloadManager
class DownloadManager : NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
static var shared = DownloadManager()
var identifier : Int = -1
var collectionId : Int = -1
var folderPath : String = ""
typealias ProgressHandler = (Int, Int, Float) -> ()
var onProgress : ProgressHandler? {
didSet { if onProgress != nil { let _ = activate() } }
}
override init() {
super.init()
}
func activate() -> URLSession {
let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background.\(NSUUID.init())")
let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
urlSession.sessionDescription = String(identifier)
urlSession.accessibilityHint = String(collectionId)
return urlSession
}
private func calculateProgress(session : URLSession, completionHandler : @escaping (Int, Int, Float) -> ()) {
session.getTasksWithCompletionHandler { (tasks, uploads, downloads) in
let progress = downloads.map({ (task) -> Float in
if task.countOfBytesExpectedToReceive > 0 {
return Float(task.countOfBytesReceived) / Float(task.countOfBytesExpectedToReceive)
} else {
return 0.0
}
})
completionHandler(session.getSessionDescription(), Int(session.accessibilityHint!)!, progress.reduce(0.0, +))
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL){
let stringNumb = (session.accessibilityHint ?? "hit")
let someNumb = Int(stringNumb as String)
let string1 = (session.sessionDescription ?? "hit")
let some1 = Int(string1 as String)
if let idx = downloadQueue[someNumb!]?.index(of: some1!) {
downloadQueue[someNumb!]?.remove(at: idx)
print("remove:\(downloadQueue)")
}
let fileName = downloadTask.originalRequest?.url?.lastPathComponent
let path = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)
let documentDirectoryPath:String = path[0]
let fileManager = FileManager()
var destinationURLForFile = URL(fileURLWithPath: documentDirectoryPath.appending("/\(folderPath)"))
do {
try fileManager.createDirectory(at: destinationURLForFile, withIntermediateDirectories: true, attributes: nil)
destinationURLForFile.appendPathComponent(String(describing: fileName!))
try fileManager.moveItem(at: location, to: destinationURLForFile)
} catch (let error) {
print(error)
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
if totalBytesExpectedToWrite > 0 {
if let onProgress = onProgress {
calculateProgress(session: session, completionHandler: onProgress)
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let stringNumb = (session.accessibilityHint ?? "hit")
let someNumb = Int(stringNumb as String)
let string1 = (session.sessionDescription ?? "hit")
let some1 = Int(string1 as String)
if let idx = downloadQueue[someNumb!]?.index(of: some1!) {
downloadQueue[someNumb!]?.remove(at: idx)
print("remove:\(downloadQueue)")
}
}
}
UICollectionView
class CollectionController: UIViewController, UICollectionViewDelegate {
typealias ProgressHandler = (Int, Float) -> ()
var onProgress : ProgressHandler?
var items = [item]()
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
let sections = Bundle.main.decode([Section].self, from: "carouselSection.json")
override func viewDidLoad() {
super.viewDidLoad()
createCollectionView()
setupScrollView()
let count = dataSource!.snapshot().numberOfItems
for index in 0...count {
items.append(item(number: index))
}
}
func setupScrollView() {
collectionView.scrollToItem(at: IndexPath(item: 0, section: 0), at: .centeredHorizontally, animated: false)
}
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch self.sections[indexPath.section].identifier {
case "carouselCell": return self.configure(CarouselCell.self, with: item, for: indexPath)
default: return self.configure(CarouselCell.self, with: item, for: indexPath)
}
}
}
func configure<T: SelfConfiguringCell>(_ cellType: T.Type, with item: Item, for indexPath: IndexPath) -> T {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { fatalError(" — \(cellType)") }
cell.configure(with: item)
return cell
}
func reloadData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(sections)
for section in sections { snapshot.appendItems(section.item, toSection: section) }
dataSource?.apply(snapshot)
}
func createCollectionView() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.isScrollEnabled = false
collectionView.delegate = self
collectionView.contentInsetAdjustmentBehavior = .never
view.addSubview(collectionView)
collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: CarouselCell.reuseIdentifier)
createDataSource()
reloadData()
}
func createCompositionalLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupWidth = (layoutEnvironment.container.contentSize.width * 1.05)/3
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupWidth))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: (layoutEnvironment.container.contentSize.height/2) - (groupWidth/2), leading: 0, bottom: 0, trailing: 0)
section.orthogonalScrollingBehavior = .groupPagingCentered
return section
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let directory: String = path[0]
let fileManager = FileManager()
let destination = URL(fileURLWithPath: directory.appendingFormat("/\(indexPath.row+1)"))
var queueArray = downloadQueue[indexPath.row+1] ?? [Int]()
queueArray.append(indexPath.row+1)
downloadQueue[indexPath.row+1] = queueArray
let url = URL(string: "link")!
let downloadManager = DownloadManager()
downloadManager.identifier = indexPath.row+1
downloadManager.collectionId = indexPath.row+1
downloadManager.folderPath = "\(indexPath.row+1)"
let downloadTaskLocal = downloadManager.activate().downloadTask(with: url)
downloadTaskLocal.resume()
var cell = self.collectionView.dequeueReusableCell(withReuseIdentifier: "carouselCell", for: indexPath) as! CarouselCell
cell = self.collectionView?.cellForItem(at: indexPath) as! CarouselCell
var item = items[indexPath.row]
downloadManager.onProgress = { (row, tableId, progress) in
print("downloadManager.onProgress:\(row), \(tableId), \(String(format: "%.f%%", progress * 100))")
DispatchQueue.main.async {
if progress <= 1.0 {
cell.progressView.progress = progress
if progress == 1.0 {
item.downloadStatus = .completed
} else {
cell.title.text = "\(String(format: "%.f%%", progress * 100))"
item.downloadStatus = .inProgress
}
}
}
}
}
}
Update for new answers:
In my project I have Item
to parse JSON and I use it in collection. And item
from downloadManager
Item for parse JSON
public struct Item: Decodable, Hashable {
let index: Int
let title: String
let image: String
let backgroundColor: String
let borderColor: String
}
Section:
public struct Section: Decodable, Hashable {
let index: Int
let identifier: String
let title: String
let subtitle: String
let item: [Item]
}
item from DownloadManager
struct item {
var number: Int!
var downloadStatus: DownloadStatus = .none
init(number: Int) { self.number = number }
}
CollectionView cell
func configure(with item: Item) {
title.text = item.title
textView.backgroundColor = UIColor(item.backgroundColor)
textView.layer.borderColor = UIColor(item.borderColor).cgColor
progressView.layer.borderColor = UIColor(item.borderColor).cgColor
imageView.image = UIImage(named: item.image)
}
As @Duncan C point out problems in your code. Here is my suggestion to refactor your code: CollectionController
will interact to DownloadManager
then store current progress to Item
, Instead of create item
to keep track of download progress, you can store download progress as local varialable in Item
:
struct Item: Decodable {
let index: Int
let title: String
let image: String
let backgroundColor: String
let borderColor: String
//local variable for Download task
//need to set this value to optional for parsing JSON work properly
var progress: CGFloat? = 0.0 // <- store progress
var downloadStatus: DownloadStatus? = .none
}
extension Item: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(index)
}
}
enum DownloadStatus: String, Decodable {
case none
case inProgress
case completed
case failed
}
public struct Section: Decodable, Hashable {
let index: Int
let identifier: String
let title: String
let subtitle: String
let item: [Item]
}
extension Item: Equatable {
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.title == rhs.title && lhs.image == rhs.image && lhs.backgroundColor == rhs.backgroundColor &&
lhs.index == rhs.index && lhs.borderColor == rhs.borderColor
}
}
class CollectionController: UIViewController, UICollectionViewDelegate {
var mapFromIndexToDownloadManager: [Item: DownloadManager] = [:] // <- cache current DownloadManager
//...
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//get Item
let item: Item = ...
if item.downloadStatus == .none || item.downloadStatus == .failed { // <- check to see if their has been an download progress for IndexPath before, also can check download status, ...
// create DownloadManager
mapFromIndexToDownloadManager[indexPath.row] = downloadManager // <- cache DownloadManager
let downloadTaskLocal = downloadManager.activate().downloadTask(with: url)
downloadTaskLocal.resume()
downloadManager.onProgress = { [weak self] (row, tableId, progress) in
guard let self = self else { return }
var currItem = self.datasource.snapshot().itemIdentifiers
currItem[row].progress = progress
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems(currItem)
// apply new snapshot to reload item
datasource.apply(snapshot)
}
}
}
}
Now in your cell, just set your progressView.progress
base on progress
property of Item
:
func configure(with item: Item) {
title.text = item.title
textView.backgroundColor = UIColor(item.backgroundColor)
textView.layer.borderColor = UIColor(item.borderColor).cgColor
progressView.layer.borderColor = UIColor(item.borderColor).cgColor
imageView.image = UIImage(named: item.image)
//set progress here
if let progress = item.progress {
cell.progressView.progress = progress
}
}
Updated Answer: As Alex want to store download progress
in seperated model from Item
. Then when download progress
got update, we have to manually reload items to update UI. Here is my minimal producuable example:
import UIKit
enum DownloadStatus {
case none
case inProgress
case completed
case failed
}
struct item {
var number: Int!
var downloadStatus: DownloadStatus = .none
var progress: Float = 0.0
init(number: Int) { self.number = number }
}
var downloadQueue = [Int: [Int]]()
var masterIndex = 0
extension URLSession {
func getSessionDescription () -> Int { return Int(self.sessionDescription!)! } // Item ID
func getDebugDescription () -> Int { return Int(self.debugDescription)! } // Collection ID
}
class DownloadManager : NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
static var shared = DownloadManager()
var identifier : Int = -1
var collectionId : Int = -1
var folderPath : String = ""
typealias ProgressHandler = (Int, Int, Float) -> ()
var onProgress : ProgressHandler? {
didSet { if onProgress != nil { let _ = activate() } }
}
override init() {
super.init()
}
func activate() -> URLSession {
let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background.\(NSUUID.init())")
let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
urlSession.sessionDescription = String(identifier)
urlSession.accessibilityHint = String(collectionId)
return urlSession
}
private func calculateProgress(session : URLSession, completionHandler : @escaping (Int, Int, Float) -> ()) {
session.getTasksWithCompletionHandler { (tasks, uploads, downloads) in
let progress = downloads.map({ (task) -> Float in
if task.countOfBytesExpectedToReceive > 0 {
return Float(task.countOfBytesReceived) / Float(task.countOfBytesExpectedToReceive)
} else {
return 0.0
}
})
completionHandler(session.getSessionDescription(), Int(session.accessibilityHint!)!, progress.reduce(0.0, +))
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL){
let stringNumb = (session.accessibilityHint ?? "hit")
let someNumb = Int(stringNumb as String)
let string1 = (session.sessionDescription ?? "hit")
let some1 = Int(string1 as String)
if let idx = downloadQueue[someNumb!]?.firstIndex(of: some1!) {
downloadQueue[someNumb!]?.remove(at: idx)
print("remove:\(downloadQueue)")
}
let fileName = downloadTask.originalRequest?.url?.lastPathComponent
let path = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)
let documentDirectoryPath:String = path[0]
let fileManager = FileManager()
var destinationURLForFile = URL(fileURLWithPath: documentDirectoryPath.appending("/\(folderPath)"))
do {
try fileManager.createDirectory(at: destinationURLForFile, withIntermediateDirectories: true, attributes: nil)
destinationURLForFile.appendPathComponent(String(describing: fileName!))
try fileManager.moveItem(at: location, to: destinationURLForFile)
} catch (let error) {
print(error)
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
if totalBytesExpectedToWrite > 0 {
if let onProgress = onProgress {
calculateProgress(session: session, completionHandler: onProgress)
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let stringNumb = (session.accessibilityHint ?? "hit")
let someNumb = Int(stringNumb as String)
let string1 = (session.sessionDescription ?? "hit")
let some1 = Int(string1 as String)
if let idx = downloadQueue[someNumb!]?.firstIndex(of: some1!) {
downloadQueue[someNumb!]?.remove(at: idx)
print("remove:\(downloadQueue)")
}
}
}
public struct Item: Decodable, Hashable {
let index: Int
let title: String
let image: String
let backgroundColor: String
let borderColor: String
}
public struct Section: Decodable, Hashable {
let index: Int
let identifier: String
let title: String
let subtitle: String
let item: [Item]
}
class CollectionController: UIViewController, UICollectionViewDelegate {
typealias ProgressHandler = (Int, Float) -> ()
var onProgress : ProgressHandler?
var items = [item]()
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
let sections: [Section] = [.init(index: 0, identifier: "carouselCell", title: "title", subtitle: "sub", item: [
Item(index: 0, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 1, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 2, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 3, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 4, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 5, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 6, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 7, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 8, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 9, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 10, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 11, title: "Hello", image: "", backgroundColor: "", borderColor: ""),
Item(index: 12, title: "Hello", image: "", backgroundColor: "", borderColor: "")
])]
override func viewDidLoad() {
super.viewDidLoad()
createCollectionView()
setupScrollView()
let count = dataSource!.snapshot().numberOfItems
for index in 0...count {
items.append(item(number: index))
}
}
func setupScrollView() {
collectionView.collectionViewLayout = createCompositionalLayout()
collectionView.scrollToItem(at: IndexPath(item: 0, section: 0), at: .centeredHorizontally, animated: false)
}
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
guard let self = self else { return UICollectionViewCell() }
switch self.sections[indexPath.section].identifier {
case "carouselCell":
let cell = self.configure(CarouselCell.self, with: item, for: indexPath)
if indexPath.row < self.items.count { // <- update UI base on self.items
cell.label.text = "\(String(format: "%.f%%", self.items[indexPath.row].progress * 100))"
}
return cell
default:
return self.configure(CarouselCell.self, with: item, for: indexPath)
}
}
}
func configure<T: SelfConfiguringCell>(_ cellType: T.Type, with item: Item, for indexPath: IndexPath) -> T {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { fatalError(" — \(cellType)") }
cell.configure(with: item)
return cell
}
func reloadData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(sections)
for section in sections { snapshot.appendItems(section.item, toSection: section) }
dataSource?.apply(snapshot)
}
func createCollectionView() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.contentInsetAdjustmentBehavior = .never
view.addSubview(collectionView)
collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: CarouselCell.reuseIdentifier)
createDataSource()
reloadData()
}
func createCompositionalLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupWidth = (layoutEnvironment.container.contentSize.width * 1.05)/3
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupWidth))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: (layoutEnvironment.container.contentSize.height/2) - (groupWidth/2), leading: 0, bottom: 0, trailing: 0)
section.interGroupSpacing = 20
section.orthogonalScrollingBehavior = .groupPagingCentered
return section
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let directory: String = path[0]
let fileManager = FileManager()
let destination = URL(fileURLWithPath: directory.appendingFormat("/\(indexPath.row+1)"))
var queueArray = downloadQueue[indexPath.row+1] ?? [Int]()
queueArray.append(indexPath.row+1)
downloadQueue[indexPath.row+1] = queueArray
let url = URL(string: "https://file-examples.com/storage/fe91352fe66730de9982024/2017/04/file_example_MP4_480_1_5MG.mp4")!
let downloadManager = DownloadManager()
downloadManager.identifier = indexPath.row+1
downloadManager.collectionId = indexPath.row+1
downloadManager.folderPath = "\(indexPath.row+1)"
let downloadTaskLocal = downloadManager.activate().downloadTask(with: url)
downloadTaskLocal.resume()
//var item = items[indexPath.row] <- only update local value
downloadManager.onProgress = { (row, tableId, progress) in
let row = row - 1
//print("downloadManager.onProgress:\(row), \(tableId), \(String(format: "%.f%%", progress * 100))")
DispatchQueue.main.async {
if progress <= 1.0 {
self.items[row].progress = progress
if progress == 1.0 {
self.items[row].downloadStatus = .completed
} else {
//cell.title.text = "\(String(format: "%.f%%", progress * 100))"
self.items[row].downloadStatus = .inProgress
}
self.reloadItem(indexPath: .init(row: row, section: 0))
}
}
}
}
func reloadItem(indexPath: IndexPath) {
guard let needReloadItem = dataSource!.itemIdentifier(for: indexPath) else { return }
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(sections)
for section in sections { snapshot.appendItems(section.item, toSection: section) }
dataSource?.apply(snapshot)
snapshot.reloadItems([needReloadItem]) // <- reload items
dataSource?.apply(snapshot, animatingDifferences: false)
}
}
protocol SelfConfiguringCell: UICollectionViewCell {
static var reuseIdentifier: String { get }
func configure(with: Item)
}
class CarouselCell: UICollectionViewCell, SelfConfiguringCell {
static var reuseIdentifier: String { "carouselCell" }
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(label)
contentView.backgroundColor = .green
label.translatesAutoresizingMaskIntoConstraints = false
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
}
func configure(with item: Item) {
}
required init?(coder: NSCoder) {
fatalError("zzzzz")
}
}