How to set a custom prefetching time for LazyVStack in SwiftUI? - ios

TL;DR: Is there some parameter or way to set the offset at which LazyVStack initialises views?
LazyVStack initialises the views lazily, so when I scroll, the next (few?) views are initialised.
I am loading an image once a view is drawn, using SDWebImage Package in swift. This takes a view milliseconds, and since I am using a LazyVStack, if one scrolls fast (even within reasonable limits), the placeholder is visible for a short moment, because the view has just been created a (too) short moment ago. If I scroll very slowly, the image loads just before the view appears, so no placeholder is visible.
If I could make the LazyVStack initialise the views just a few milliseconds earlier my problem would be gone...
Once would think this is a pretty common problem, timing this initialisation just right so as not to load too early or too late.. but nothing at all in the docs about this

this process is called as prefetching -because you're prefetching them so it will look smooth-
And sorry, but there's no way to access prefetching of LazyVStack in SwiftUI right now. Also, keep in mind that both SwiftUI's Grid And LazyH/VStack is not performant as UIKit's UICollectionView. So what you could do here is you can use UICollectionView's UICollectionViewDataSourcePrefetching protocol in your collection view's data.
I used SDWebImage Library to Fetch Images from internet (one of the most popular libraries for UIKit)
I tried to explain everything as comments in the code so give your attention to them, here's what it looks like:
here's the code:
import SwiftUI
import SDWebImage
struct CollectionView: UIViewRepresentable {
let items: [String]
func makeUIView(context: Context) -> UICollectionView {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
collectionView.register(ImageCell.self, forCellWithReuseIdentifier: "ImageCell")
collectionView.delegate = context.coordinator
collectionView.dataSource = context.coordinator
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
// Reload the collection view data if the items array changes
uiView.reloadData()
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDataSourcePrefetching {
let parent: CollectionView
init(_ collectionView: CollectionView) {
self.parent = collectionView
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return parent.items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCell
let item = parent.items[indexPath.item]
// Set the progress of the progress view as the image is being downloaded
cell.progressView.progress = 0.0
SDWebImageDownloader.shared.downloadImage(with: URL(string: item), options: .highPriority, progress: { (receivedSize, expectedSize, url) in
DispatchQueue.main.async {
cell.progressView.progress = Float(receivedSize) / Float(expectedSize)
}
}) { (image, data, error, finished) in
DispatchQueue.main.async {
cell.imageView.sd_setImage(with: URL(string: item))
cell.progressView.isHidden = true
}
}
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: 100, height: 100)
}
// MARK: - UICollectionViewDataSourcePrefetching
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
// Filter the index paths to only include the ones that are within the desired range, trick relies on here
// In our example, i'm fetching 6 items beforehand which equals 2 rows, so i'm prefetching 2 rows beforehand. you can increase that amount if you w ant to
let prefetchIndexPaths = indexPaths.filter { $0.item < collectionView.numberOfItems(inSection: $0.section) - 6 }
let urls = prefetchIndexPaths.compactMap { URL(string: self.parent.items[$0.item])! }
SDWebImagePrefetcher.shared.prefetchURLs(urls)
}
func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
// Cancel the prefetching for the given index paths, this is not required but i wanted to add it
let urls = indexPaths.map { URL(string: self.parent.items[$0.item]) }
}
}
}
class ImageCell: UICollectionViewCell {
let imageView = UIImageView()
let progressView = UIProgressView()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(imageView)
addSubview(progressView)
// if you're not familiar with uikit this is just a disgusting uikit code to make proper layouts :(
progressView.translatesAutoresizingMaskIntoConstraints = false
progressView.topAnchor.constraint(equalTo: topAnchor).isActive = true
progressView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
progressView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.topAnchor.constraint(equalTo: progressView.bottomAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
imageView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
imageView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
and Here's how you can implement it to swiftui:
struct ContentView: View {
var items : [String] {
var i = 0
var _items = [String]()
while (i < 900) {
_items.append("https://picsum.photos/\(Int.random(in: 300..<600))/\(Int.random(in: 300..<600))")
i = i + 1
}
return _items
}
var body: some View {
CollectionView(items: items)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I used lorem picsum which is a website for generating random images and that's why you see images reloading randomly in my sample(that white ones), in your case, this shouldn't be a problem

Since I was using SDWebImageSwiftUI before, simply calling the following already before the view starts to initialise solved my problem:
SDWebImagePrefetcher.shared.prefetchURLs(urls) { finishedCount, skippedCount in
print("preloading complete")
}
then in my LazyVStack I use:
LazyVStack {
ForEach(items, id: \.self) { item in
ItemView(item: item)
.onAppear {
// calling function to prefetch next x-items by their url
}
}
}
}

Related

Unable to display thumbnails in UICollectionView

I am trying to recreate this thing. I've created in Storyboard skeleton. Here's the idea of my code:
Fetch images from URL's array with help of the function getThumbnailFromImage
Add UIImage's with my thumbnails in array webImages
Add in ViewController reusable cell MyCollectionView
...
But here I am with this))) (Don't mind absence of Auto Layout). What am I doing wrong? I think that the problem is with reloadData() but I don't know where to put it.
ViewController:
//
// ViewController.swift
// youtube-clone
//
// Created by мас on 16.08.2022.
//
import Foundation
import UIKit
import YouTubePlayer
import AVFoundation
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
var url: [URL?] = [
URL(string: "https://www.youtube.com/watch?v=KhebpuFBD14"),
URL(string: "https://www.youtube.com/watch?v=UfNdNrRHpUw"),
URL(string: "https://www.youtube.com/watch?v=CX-BdDHW0Ho"),
URL(string: "https://www.youtube.com/watch?v=NIOMtSzfpck")
]
var webImages: [UIImage] = []
var currentPage: Int = 0
#IBOutlet var myPage: UIPageControl!
#IBOutlet weak var buttonInfo: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
myPage.currentPage = 0
myPage.numberOfPages = webImages.count
}
// MARK: - Collection View Setup
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return webImages.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionCell
getThumbnailFromImage(url: url[indexPath.row]!, completion: { image in
self.webImages.append(image!)
})
cell.myWebImage.image = webImages[indexPath.row]
cell.myWebImage.layer.cornerRadius = 20
return cell
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
myPage.currentPage = indexPath.row
}
// MARK: - Layout Setup // IGNORE IT
func setupLayout() {
buttonInfo.layer.cornerRadius = 25
buttonInfo.imageView!.transform = CGAffineTransform(rotationAngle: 180 * .pi / 180)
self.navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
}
// MARK: - Videos Thumbnail Fetcher
func getThumbnailFromImage(url: URL, completion: #escaping ((_ image: UIImage?) -> Void)) {
DispatchQueue.global().async {
let asset = AVAsset(url: url)
let avAssetImageGenerator = AVAssetImageGenerator(asset: asset)
avAssetImageGenerator.appliesPreferredTrackTransform = true
let thumbnailTime = CMTimeMake(value: 7, timescale: 1)
do {
let cgThumbImage = try avAssetImageGenerator.copyCGImage(at: thumbnailTime, actualTime: nil)
let thumbImage = UIImage(cgImage: cgThumbImage)
DispatchQueue.main.async {
completion(thumbImage)
}
}
catch {
print(error.localizedDescription)
}
}
}
}
Reusable Cell AKA MyCollectionCell:
import UIKit
class MyCollectionCell: UICollectionViewCell {
#IBOutlet var myWebImage: UIImageView!
}
P.s.: YouTubePlayer is custom pod from GitHub, it's not currently used.
You do NOT have to use AVAssetImageGenerator, Simply you can use Youtube API to fetch the thumbnail images as .jpg image by video id,
and each YouTube video has four generated images.
https://img.youtube.com/vi/{id}/0.jpg
https://img.youtube.com/vi/{id}/1.jpg
https://img.youtube.com/vi/{id}/2.jpg
https://img.youtube.com/vi/{id}/3.jpg
Example
https://img.youtube.com/vi/KhebpuFBD14/0.jpg
And then it is preferred to use a third party to load this image as its displayed in a list, like https://github.com/SDWebImage/SDWebImage or https://github.com/onevcat/Kingfisher and you will NOT be worry about Concurrency or caching.
A couple of thoughts:
#matt is right in the comment - getThumbnailFromImage will likely not have called the completion block by the time cellForItemAt returns.
From what is visible in the code you posted, webImages.count will still be 0 when your collection view checks numberOfItemsInSection. If the number of items is 0, cellForItemAt may never get called so the call to getThumbnailFromImage wouldn't even be reached. (I'm not sure if the white box in your screenshot is part of a cell or another view element. If a cell is being displayed, I'm assuming you're populating webImages somewhere else before the collection view gets laid out).
One way you could work around these issues is by giving each cell a URL rather than a thumbnail. That way the cell can be displayed while the image is still loading. The cell could look something like this:
class MyCollectionCell: UICollectionViewCell {
#IBOutlet var myWebImage: UIImageView!
func configure(urlString: String) {
guard let self = self, let url = URL(string: urlString) else {
return
}
getThumbnailFromImage(url: url, completion: { [weak self] image in
self?.myWebImage.image = image
})
}
// Move `getThumbnailForImage` function to here, or give the cell a delegate to call back to the VC with if you don't want any networking in the view itself
}
The cellForItemAt function in the VC would need to be changed to something like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionCell
cell.configure(urlString: url[indexPath.row])
cell.myWebImage.layer.cornerRadius = 20 // This should probably live in the cell since the parent doesn't actually need to know about it!
return cell
}
An added benefit of this approach is that you're not referencing a separate array of images that could theoretically end up being in the wrong order if there's a mistake somewhere in the code. You could get rid of the webImages array entirely and use urls.count in numberOfItemsInSection instead - or eventually the number of elements returned from an API somewhere.
Side note - make sure you add [weak self] at the beginning of any closure that references self to avoid trying to access it after it's been deallocated! Currently the call to getThumbnailFromImage doesn't have that :)
Also, note that I changed to a guard statement for checking that the URL exists. This is much safer than force unwrapping a URL(string:) value, especially if you ever end up getting the strings from a dynamic source.

Image on CollectionView cell changes when scrolling

I have a collectionView that has custom cells. Each custom cell has an image in it and other components. The problem I'm having is that when I scroll, the images changes to another images from other cells on the collectionView. I'm gonna put the code that I'm using so it's more clear to see how I'm implementing the collectionView and it's cell.
func registerCell() {
collectionView.register(PageCell.self, forCellWithReuseIdentifier: cellId)
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let pageNumber = Int(targetContentOffset.pointee.x / view.frame.width)
pageControl.currentPage = pageNumber
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return feedTitles.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! PageCell
cell.titleLabel.text = self.feedTitles[indexPath.item].uppercased()
cell.textView.text = self.feedDescription[indexPath.item]
guard let imageURL = URL(string: self.feedImages[indexPath.item]) else {return cell}
cell.imageView.load(url: imageURL)
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
This is the code for getting the images from a server:
var feeds: [Feed]?
func getFeed(){
feedService.getFeed() { [weak self](feedRes) in
switch feedRes {
case .success(let successResponse):
print("Success Feed")
if successResponse.data.count < 1 {
} else{
self?.feeds = successResponse.data
self?.feedTitles = self?.feeds?.compactMap({ $0.title
}) ?? ["Titulo Noticia"]
self?.feedImages = self?.feeds?.compactMap({ $0.image
}) ?? ["Imagen"]
self?.feedDescription = self?.feeds?.compactMap({ $0.content
}) ?? ["Descripcion Imagen"]
self?.collectionView.reloadData()
self?.pageControl.numberOfPages = (self?.feedTitles.count)!
self?.pageControl.isHidden = false
}
case .failure(let feedError):
print("Fail Feed \(feedError)")
print(feedError.localizedDescription)
self?.pageControl.isHidden = true
}
}
}
This is the "load" function:
func load(url: URL) {
DispatchQueue.global().async { [weak self] in
if let data = try? Data(contentsOf: url) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self?.image = image
}
}
}
}
}
The issue is inside your cell.imageView.load(url: imageURL) implementation.
Downloading an image is an asynchronous operation. When you create a cell - you start an operation and you don't know when it's going to complete. Whenever it completes, it sets the image on your UIImageView instance.
The cell on the other hand is a reuseable component for collectionView and when you scroll, same cell instance that disappeared by going out of screen from top will also reappear by coming in from bottom of the screen. This means that one cell instance can trigger multiple image download requests and it doesn't cancel it's previous in-flight image download request. It also does not know which image download call completed - the last one (or the one before that etc.)
What you need to do is - add some additional protection in the imageView load method so that when a cell (and hence imageView) instance is reused - it only sets the image for last request (not the ones done prior to the last one).
Copy paste following code into your project.
import UIKit
import ObjectiveC.runtime
private var keyTagIdentifier: String?
public extension UIView {
var tagIdentifier: String? {
get { return objc_getAssociatedObject(self, &keyTagIdentifier) as? String }
set { objc_setAssociatedObject(self, &keyTagIdentifier, newValue, .OBJC_ASSOCIATION_RETAIN) }
}
}
Update your image load extension like this
public extension UIImageView {
func load(url: URL) {
// 1. Assign the current request identifier on this instance
let lastRequestIdentifier = url.absoluteString
self.tagIdentifier = lastRequestIdentifier
// 2. Download the image as you are doing today
// 3. After the image has been downloaded and you set the image
// Please check whether we are still interested in same image or not
// This will be written inside your image download completion block
if self.tagIdentifier == lastRequestIdentifier {
self.image = // the image you downloaded
}
}
}
I believe the issue is due to UICollectionView reusing cells. Meaning the cell that disappears from the top, is rendered at the bottom, usually with the same state as the cell that disappeared from the top and vice-versa for the cells at the bottom. You can avoid this behaviour by adding the following to you code to your PageCell,
override func prepareForReuse() {
super.prepareForReuse()
imageView?.image = nil
}

UICollectionViewCell created from XIB will cause flickering during drag and drop

I implement a simple drag and drop sample.
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
private var collectionView: UICollectionView?
var colors: [UIColor] = [
.link,
.systemGreen,
.systemBlue,
.red,
.systemOrange,
.black,
.systemPurple,
.systemYellow,
.systemPink,
.link,
.systemGreen,
.systemBlue,
.red,
.systemOrange,
.black,
.systemPurple,
.systemYellow,
.systemPink
]
override func viewDidLoad() {
super.viewDidLoad()
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.itemSize = CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
//collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
collectionView?.delegate = self
collectionView?.dataSource = self
collectionView?.backgroundColor = .white
view.addSubview(collectionView!)
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture))
collectionView?.addGestureRecognizer(gesture)
}
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
guard let collectionView = collectionView else {
return
}
switch gesture.state {
case .began:
guard let targetIndexPath = collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
return
}
collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
case .changed:
collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: collectionView))
case .ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView?.frame = view.bounds
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.backgroundColor = colors[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
}
func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let item = colors.remove(at: sourceIndexPath.row)
colors.insert(item, at: destinationIndexPath.row)
}
}
However, I notice that, if my UICollectionViewCell is created with XIB, it will randomly exhibit flickering behaviour, during drag and drop.
The CustomCollectionViewCell is a pretty straightforward code.
CustomCollectionViewCell.swift
import UIKit
extension UIView {
static func instanceFromNib() -> Self {
return getUINib().instantiate(withOwner: self, options: nil)[0] as! Self
}
static func getUINib() -> UINib {
return UINib(nibName: String(describing: self), bundle: nil)
}
}
class CustomCollectionViewCell: UICollectionViewCell {
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
}
Flickering
By using the following code
let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
It will have the following random flickering behaviour - https://youtu.be/CbcUAHlRJKI
No flickering
However, if the following code is used instead
collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
Things work fine. There are no flickering behaviour - https://youtu.be/QkV2HlIrXK8
May I know why it is so? How can I avoid the flickering behaviour, when my custom UICollectionView is created from XIB?
Please note that, the flickering behaviour doesn't happen all the time. It happens randomly. It is easier to reproduce the problem using real iPhone device, than simulator.
Here's the complete sample code - https://github.com/yccheok/xib-view-cell-cause-flickering
While we are rearranging cells in UICollectionView (gesture is active), it handles all of the cell movements for us (without having us to worry about changing dataSource while the rearrange is in flight).
At the end of this rearrange gesture, UICollectionView rightfully expects that we will reflect the change in our dataSource as well which you are doing correctly here.
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let item = colors.remove(at: sourceIndexPath.row)
colors.insert(item, at: destinationIndexPath.row)
}
Since UICollectionView expects a dataSource update from our side, it performs following steps -
Call our collectionView(_:, moveItemAt:, to:) implementation to provide us a chance to reflect the changes in dataSource.
Call our collectionView(_:, cellForItemAt:) implementation for the destinationIndexPath value from call #1, to re-create a new cell at that indexPath from scratch.
Okay, but why would it perform step 2 even if this is the correct cell to be at that indexPath?
It's because UICollectionView doesn't know for sure whether you actually made those dataSource changes or not. What happens if you don't make those changes? - now your dataSource & UI are out of sync.
In order to make sure that your dataSource changes are correctly reflected in the UI, it has to do this step.
Now when the cell is being re-created, you sometimes see the flicker. Let the UI reload the first time, put a breakpoint in the cellForItemAt: implementation at the first line and rearrange a cell. Right after rearrange completes, your program will pause at that breakpoint and you can see following on the screen.
Why does it not happen with UICollectionViewCell class (not XIB)?
It does (as noted by others) - it's less frequent. Using the above steps by putting a breakpoint, you can catch it in that state.
How to solve this?
Get a reference to the cell that's currently being dragged.
Return this instance from cellForItemAt: implementation.
var currentlyBeingDraggedCell: UICollectionViewCell?
var willRecreateCellAtDraggedIndexPath: Bool = false
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
guard let cv = collectionView else { return }
let location = gesture.location(in: cv)
switch gesture.state {
case .began:
guard let targetIndexPath = cv.indexPathForItem(at: location) else { return }
currentlyBeingDraggedCell = cv.cellForItem(at: targetIndexPath)
cv.beginInteractiveMovementForItem(at: targetIndexPath)
case .changed:
cv.updateInteractiveMovementTargetPosition(location)
case .ended:
willRecreateCellAtDraggedIndexPath = true
cv.endInteractiveMovement()
default:
cv.cancelInteractiveMovement()
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if willRecreateCellAtDraggedIndexPath,
let currentlyBeingDraggedCell = currentlyBeingDraggedCell {
self.willRecreateCellAtDraggedIndexPath = false
self.currentlyBeingDraggedCell = nil
return currentlyBeingDraggedCell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentView.backgroundColor = colors[indexPath.item]
return cell
}
Will this solve the problem 100%?
NO. UICollectionView will still remove the cell from it's view hierarchy and ask us for a new cell - we are just providing it with an existing cell instance (that we know is going to be correct according to our own implementation).
You can still catch it in the state where it disappears from UI before appearing again. However this time there's almost no work to be done, so it will be significantly faster and you will see the flickering less often.
BONUS
iOS 15 seems to be working on similar problems via UICollectionView.reconfigureItems APIs. See an explanation in following Twitter thread.
Whether these improvements will land in rearrange or not, we will have to see.
Other Observations
Your UICollectionViewCell subclass' XIB looks like following
However it should look like following (1st one is missing contentView wrapper, you get this by default when you drag a Collection View Cell to the XIB from the View library OR create a UICollectionViewCell subclass with XIB).
And your implementation uses -
cell.backgroundColor = colors[indexPath.row]
You should use contentView to do all the UI customization, also note the indexPath.item(vs row) that better fits with cellForItemAt: terminology (There are no differences in these values though). cellForRowAt: & indexPath.row are more suited for UITableView instances.
cell.contentView.backgroundColor = colors[indexPath.item]
UPDATE
Should I use this workaround for my app in production?
NO.
As noted by OP in the comments below -
The proposed workaround has 2 shortcomings.
(1) Missing cell
(2) Wrong content cell.
This is clearly visible in https://www.youtube.com/watch?v=uDRgo0Jczuw Even if you perform explicit currentlyBeingDraggedCell.backgroundColor = colors[indexPath.item] within if block, wrong content cell issue is still there.
The flickering is caused by the cell being recreated at its new position. You can try holding to the cell.
(only the relevant code is shown)
// keeps a reference to the cell being dragged
private weak var draggedCell: UICollectionViewCell?
// the flag is set when the dragging completes
private var didInteractiveMovementEnd = false
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
// keep cell reference
draggedCell = collectionView.cellForItem(at: targetIndexPath)
collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
case .ended:
// reuse the cell in `cellForItem`
didInteractiveMovementEnd = true
collectionView.performBatchUpdates {
collectionView.endInteractiveMovement()
} completion: { completed in
self.draggedCell = nil
self.didInteractiveMovementEnd = false
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// reuse the dragged cell
if didInteractiveMovementEnd, let draggedCell = draggedCell {
return draggedCell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
...
}

Check if cell completely displayed in collectionview

I have a collection view where the cell is of the size exactly to the collectionView, so each cell should occupy the whole screen. I have implemented a functionality where the cell is snapped to the complete view whenever it's dragged or decelerated through the scroll. This is how the UX works.
https://drive.google.com/open?id=1v8-WxCQUzfu8V_k9zM1UCWsf_-Zz4dpr
What I want:
As you can see from the clip, the cell snaps to the whole screen. Now, I want to execute a method after it snaps. Not before or not when it's partially displayed.
Following is the code I have written for snapping effect :
func scrollToMostVisibleCell(){
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
let visibleIndexPath = collectionView.indexPathForItem(at: visiblePoint)!
collectionView.scrollToItem(at: visibleIndexPath as IndexPath, at: .top, animated: true)
print("cell is ---> ", visibleIndexPath.row)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollToMostVisibleCell()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
scrollToMostVisibleCell()
if !decelerate {
scrollToMostVisibleCell()
}
}
If I use willDisplayCell method, then it' just going to return me as soon as the cell is in the view, even if it's just peeping in the collectionView.
Is there a way where I can check if the cell is completely in the view and then I can perform a function?
I have scrapped the internet over this question, but ain't able to find a satisfactory answer.
Here is a complete example of a "full screen" vertical scrolling collection view controller, with paging enabled (5 solid color cells). When the cell has "snapped into place" it will trigger scrollViewDidEndDecelerating where you can get the index of the current cell and perform whatever actions you like.
Add a new UICollectionViewController to your storyboard, and assign its class to VerticalPagingCollectionViewController. No need to change any of the default settings for the controller in storyboard - it's all handled in the code below:
//
// VerticalPagingCollectionViewController.swift
//
// Created by Don Mag on 10/31/18.
//
import UIKit
private let reuseIdentifier = "Cell"
class VerticalPagingCollectionViewController: UICollectionViewController {
private var collectionViewFlowLayout: UICollectionViewFlowLayout {
return collectionViewLayout as! UICollectionViewFlowLayout
}
private var colors: [UIColor] = [.red, .green, .blue, .yellow, .orange]
override func viewDidLoad() {
super.viewDidLoad()
// Register cell classes
self.collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
// enable paging
self.collectionView?.isPagingEnabled = true
// set section insets and item spacing to Zero
collectionViewFlowLayout.sectionInset = UIEdgeInsets.zero
collectionViewFlowLayout.minimumLineSpacing = 0
collectionViewFlowLayout.minimumInteritemSpacing = 0
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let cv = collectionViewLayout.collectionView {
collectionViewFlowLayout.itemSize = cv.frame.size
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let iPath = collectionView?.indexPathsForVisibleItems.first {
print("DidEndDecelerating - visible cell is: ", iPath)
// do what you want here...
}
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
cell.backgroundColor = colors[indexPath.item]
return cell
}
}
Using followedCollectionView.indexPathsForVisibleItems() to get visible cells visibleIndexPaths and check your indexPath is contained in visibleIndexPaths or not, before doing anything with cells.
Ref : #anhtu Check whether cell at indexPath is visible on screen UICollectionView
Also from Apple : var visibleCells: [UICollectionViewCell] { get } . Returns an array of visible cells currently displayed by the collection view.

Collection view cellForItemAt indexPath not getting called (Swift)

I created a collection view controller from story board, and set its custom class to ItemCollectionVC, the custom class of its cell to ItemCell, and set its reuse identifier to Cell
Here's my ItemCollectionVC class:
import UIKit
private let reuseIdentifier = "Cell"
class ItemCollectionVC: UICollectionViewController {
var dataSourceItems: [Items] = []
var counterBuildItems: [Items] {
let weaponItemArray = WeaponItems.weaponItems as [Items]
let defenseItemArray = DefenseItems.defenseItems as [Items]
return weaponItemArray + defenseItemArray
}
var freeBuildItems = WeaponItems.weaponItems as [Items]
var captureKrakenItems: [Items] {
let weaponItemArray = WeaponItems.weaponItems as [Items]
let abilityItemArray = AbilityItems.abilityItems as [Items]
return weaponItemArray + abilityItemArray
}
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSourceItems.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! ItemCell
cell.cellImage.image = dataSourceItems[indexPath.row].image
print(dataSourceItems.count)
return cell
}
}
When the collection view controller is presented, it's empty, what could cause the problem?
One of three things caused this problem, pretty much every time I have encountered it, in a TableView or CollectionView:
1) Your ViewController is not the dataSource of your UICollectionView
2) numberOfRows or numberOfSections method returns 0
3) The height of your cell is 0, either due to constraint problems, or a heightForCell method being not/improperly implemented.
It's impossible to say which of these is your problem, and it's always possible that you've encountered something strange. Make certain that none of these is your problems, before exploring less likely options.
If you are pretty sure that the dataSource of the collectionView is connected to the viewController (it should be by default), then you should reloadData() because the collectionView reading from dataSourceItems. To understand the case, add a break point in cellForItemAt and add another one in viewDidAppear and check which one is called first?
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
collectionView.reloadData()
}
Hope that helped.
Some things that you could easily miss if you use storyboard
1) Don't forget that content in cell must have connected Top, Bottom constraint and
content view must have height, by this cell will know to set height for cell. If you don't have these, cell height will be 0, and function cellForItemAt will never get called.
2) You can use cell layout to set dynamic cell and height for cell if you use this function:
func collectionView(_ collectionView: UICollectionView,layout collectionViewLayout:
UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) ->
CGSize {return CGSize(width: 20.00, height: 20.00)}
I fixed my problem by initializing UICollectionView properly like the following:
fileprivate let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: UICollectionViewFlowLayout.init())
Check if you get the right number of sections in the numberOfItemsInSection method for the collection view.
If you are adding a flow layout to the collection view, remove the flow layout, and check if the collection view cells show now.
If they do, just adjust your collection view flow layout code it should look like this
let _flowLayout = UICollectionViewFlowLayout()
_flowLayout.sectionInset = UIEdgeInsets(top:0, left: 0, bottom: 0, right: 0)
_flowLayout.scrollDirection = .vertical
yourCollectionView.collectionViewLayout = _flowLayout
you can set the inset to fit your use.
You have to add your code in viewWillAppear, then it will work properly.
/*
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
Like this :-
override func viewWillAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
*/
In my case there was a problem with the collection view contentInset, try adding below code in your collection view sub class.
override func layoutSubviews() {
super.layoutSubviews()
self.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)
}
override func reloadData() {
DispatchQueue.main.async {
super.reloadData()
}
}

Resources