I have a collection view. When I touch the cell, the animation from the collection view cell class CarouselCell
playing. When I press on a cell, the process of downloading the file begins in the method didSelectItemAt
. To update the process I use the code from here reloadItem(indexPath: IndexPath)
The problem is that if while downloading a file I try to touch a cell, the animation will play once, like a jump or hitch, and then it won’t work at all when touched until the file is downloaded. Also, during downloading, when the cell is clicked again, the method didSelectItemAt
will not be called until the file is downloaded. The fact that the didSelectItemAt
method is not called during file downloading is good. This prevents the same file from being downloaded multiple times. But the problem is that I don’t understand why this happens because I haven’t written such code anywhere. How to fix the animation and why didSelectItemAt
not called during the file is downloading?
code:
I removed all the download related code to save space in question and replaced it with a timer to simulate the file download progress
collection:
class CarouselController: UIViewController, UICollectionViewDelegate {
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
let sections = Bundle.main.decode([Section].self, from: "carouselData.json")
var progressA = 0.0
override func viewDidLoad() {
super.viewDidLoad()
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()
}
@objc func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch self.sections[indexPath.section].identifier {
case "carouselCell":
let cell = self.configure(CarouselCell.self, with: item, for: indexPath)
cell.title.text = "\(self.progressA)"
print(self.progressA)
return cell
default: return self.configure(CarouselCell.self, with: item, for: indexPath)
}
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
_ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in
guard self.progressA <= 1.0 else {
timer.invalidate()
self.progressA = 0.0
return
}
self.progressA += 0.01
self.reloadItem(indexPath: .init(row: indexPath.row, section: 0))
}
}
func reloadItem(indexPath: IndexPath) {
guard let needReloadItem = dataSource!.itemIdentifier(for: indexPath) else { return }
guard var snapshot = dataSource?.snapshot() else { return }
snapshot.reloadItems([needReloadItem])
dataSource?.apply(snapshot, animatingDifferences: false)
}
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 = 64
section.orthogonalScrollingBehavior = .groupPagingCentered
section.contentInsetsReference = .none
return section
}
}
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)
}
}
cell
import Combine
class CarouselCell: UICollectionViewCell, SelfConfiguringCell {
static let reuseIdentifier: String = "carouselCell"
var imageView: UIImageView = {
let image = UIImageView()
image.contentMode = .scaleToFill
image.translatesAutoresizingMaskIntoConstraints = false
return image
}()
var textView: UIView = {
let view = UIView()
view.backgroundColor = .blue
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
var title: UILabel = {
let label = UILabel()
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override var isHighlighted: Bool {
didSet { if oldValue == false && isHighlighted { animateScale(to: 0.9, duration: 0.4) }
else if oldValue == true && !isHighlighted { animateScale(to: 1, duration: 0.38) }
}
}
var imageTask: AnyCancellable?
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
imageTask?.cancel()
}
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(imageView)
contentView.addSubview(textView)
textView.addSubview(title)
imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0).isActive = true
imageView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.7).isActive = true
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0).isActive = true
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0).isActive = true
textView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.16).isActive = true
textView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24).isActive = true
textView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24).isActive = true
textView.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: -6).isActive = true
textView.layer.cornerRadius = contentView.frame.size.height*0.16/2
title.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 0).isActive = true
title.trailingAnchor.constraint(equalTo: textView.trailingAnchor, constant: 0).isActive = true
title.topAnchor.constraint(equalTo: textView.topAnchor, constant: 0).isActive = true
title.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 0).isActive = true
}
private func animateScale(to scale: CGFloat, duration: TimeInterval) {
UIView.animate( withDuration: duration, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: [.beginFromCurrentState], animations: {
self.imageView.transform = .init(scaleX: scale, y: scale)
self.textView.transform = .init(scaleX: scale, y: scale)
}, completion: nil)
}
func configure(with item: Item) {
title.text = "item.title"
textView.backgroundColor = .green
imageTask = Future<UIImage?, Never>() { promise in
UIImage(named: item.image)?.prepareForDisplay(completionHandler: { loadedImage in
promise(Result.success(loadedImage))
})
}
.receive(on: DispatchQueue.main)
.sink { image in
self.imageView.image = UIImage(named: "cover2")
}
}
required init?(coder: NSCoder) {
fatalError("error")
}
}
additional:
extension Bundle {
func decode<T: Decodable>(_ type: T.Type, from file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
protocol SelfConfiguringCell {
static var reuseIdentifier: String { get }
func configure(with item: Item)
}
public struct Section: Decodable, Hashable { let identifier: String; let item: [Item] }
public struct Item: Decodable, Hashable { let index: Int; let title: String; let image: String }
[ {
"identifier": "carouselCell",
"item": [
{
"index": 1,
"title": "0",
"image": "cover1",
},
] }, ]
based on @Rob's answer
KVO variant (not working)
I get progress inside the observer, but the cell text is not updated. Why?
var nameObservation: NSKeyValueObservation?
@objc dynamic var progress = 0.0
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch self.sections[indexPath.section].identifier {
case "carouselCell":
let cell = self.configure(CarouselCell.self, with: item, for: indexPath)
self.nameObservation = self.observe(\.progress, options: .new) { vc, change in
cell.title.text = "\(self.progress)"
}
return cell
default: return self.configure(CarouselCell.self, with: item, for: indexPath)
}
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
_ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in
guard self.progress <= 1.0 else {
timer.invalidate()
self.progress = 0.0
return
}
}
}
Notification variant (not working).
collection is despairing after press
var nameObservation: NSKeyValueObservation?
@objc func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch self.sections[indexPath.section].identifier {
case "carouselCell":
let cell = self.configure(CarouselCell.self, with: item, for: indexPath)
cell.title.text = "\(self.progressA)"
print(self.progressA)
return cell
default: return self.configure(CarouselCell.self, with: item, for: indexPath)
}
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
NotificationCenter.default.addObserver(
self, selector: #selector(self.createDataSource),
name: Notification.Name(rawValue: "sound-CarouselController"), object: nil)
_ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in
guard self.progressA <= 1.0 else {
timer.invalidate()
self.progressA = 0.0
return
}
self.progressA += 0.01
NotificationCenter.default.post(name: Notification.Name(rawValue: "sound-CarouselController"), object: nil)
}
}
There are a few issues:
The reloading of a cell while isHighlighted
is true
can confuse the cell’s isHighlighted
state management.
I might advise that you refrain from reloading of the whole cell, and instead just have the cell use a different mechanism for updating itself based upon the state of the download. E.g., maybe add a progress
property for the have the cell observe a published property. Or use the notification center post notifications with the progress and have the cell observe those notifications. See https://github.com/robertmryan/Carousel for an example.
But having the cell update itself (rather than having the view controller repeatedly reload it, especially during the isHighlighted
animation), avoids this animation interruption.
This may be a secondary concern, but I found that the selection of the cell was more reliable when all of the cell subviews specify isUserInteractionEnabled
of false
(unless, of course, you need user interaction for those subviews, which is not the case at this point at least).
Some unrelated observations:
I would advise against using the IndexPath
to identify which cell is being updated. You want to make sure that your asynchronous process will be unaffected by the insertion or deletion of cells in the collection view. That might not be something you are contemplating at this point, but using the IndexPath
is a brittle pattern that can bite you later.
Needless to say, once you move the progress updates to the cell itself, you will also make sure you correctly handle the cell being reused for a different download (as the cell in question might scroll out of view and be reused).
You said:
during downloading, when the cell is clicked again, the method
didSelectItemAt
will not be called until the file is downloaded. The fact that thedidSelectItemAt
method is not called during file downloading is good.
I would suggest that you really do want didSelectItemAt
to be called whenever the cell is selected. Now, obviously, you will want to keep track of whether the download has already started or not so you don't start duplicative downloads. But do not rely on the current behavior where didSelectItemAt
wasn't called, as that is a mistake.
Note, your view controller has a progress
state variable. You presumably want to support multiple concurrent downloads. This progress
really belongs in your model at the row level, not the view controller level. (I suspect you introduced this for the sake of the MRE, but just saying…)