#StackBounty: #ios #swift #uitableview #uiwebview How to optimize loading large and varied HTML content in UIWebView inside multiple Ta…

Bounty: 50

I have a UITableViewCell that has a UIWebView inside it.
Data I load from the server has several comments containing rich HTML content(text, images, links, etc.) As the cell contains UIWebView, I have to wait till the entire content is loaded to get the height of the tableViewCell and update it.

The issue I’m running into is that as I scroll down the cells get updated and the whole view jerks and flickers. Basically, I’m looking for a way to preload the webviews to get their height beforehand. Also, this jerking only happens until all the cells have loaded the content since I’m storing the height of each cell locally.

Here’s an example of the jittery experience.

Code of cellForRowIndex

open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(indexPath: indexPath) as LiMessageDetailTableViewCell
    cell.delegate = self
    cell.cellModel = LiMessageDetailTableViewCellModel(data: messageObject.originalMessage, index: indexPath)
    cell.heightOfWebView = messageObject.contentHeightsOriginalMessage[indexPath.row]
    if messageObject.contentHeightsOriginalMessage[indexPath.row] != 0.0 {
        cell.updateHeightConstrain()
    }
    return cell
}

WebView delegate method in TableViewCell

 //MARK:- WEBVIEW DELEGATE
    func webViewDidFinishLoad(_ webView: UIWebView) {
     //This section resizes the webview according to the new content loaded.
        var mframe = webView.frame
        mframe.size.height = 1
        webView.frame = mframe
        let fittingSize = webView.sizeThatFits(.zero)
        mframe.size = fittingSize
        webView.frame = mframe
    /**
    I found that javascript gives a more accurate height when images are
    included since they take more time loading than the normal content and 
    hence sizeThatFits does not always return proper result.
    **/
        let heightInString = webView.stringByEvaluatingJavaScript(from: "document.body.scrollHeight") ?? ""
        guard let heightInFloat = Float(heightInString) else { return }
        guard let index = cellModel?.indexPath else {return}
        constrainHeightOfWebView.constant = fittingSize.height
        guard let cellType = cellModel?.messageType  else { return }
        delegate?.updateHeight(index: index, newHeight: CGFloat(heightInFloat), cellType: cellType)
    }

Delgate method which updates the height

func updateHeight(index: IndexPath, newHeight: CGFloat, cellType: LiMessageType) {
    switch cellType {
    case .originalMessage:
        if msgObj.contentHeightsOriginalMessage[index.row] != 0.0 && msgObj.contentHeightsOriginalMessage[index.row] == newHeight {
            return
        }
        msgObj.contentHeightsOriginalMessage[index.row] = newHeight
   .....
    }
    self.tableView.beginUpdates()
    self.tableView.endUpdates()
}

Another issue is that if the webview contains heavy images then the whole webview flickers as it reloads everytime the cell is dequeued, causing a bad experience.

Here’s the example of webview with image reloading

The image issue resolves itself after some time as webview gets cached, but nevertheless a bad experience.

Is there a way to solve these issues?


Get this bounty!!!

#StackBounty: #ios #swift #uitextfield Cursor goes to end after setting text to UITextField inside shouldChangeCharactersIn

Bounty: 50

I have text label which has phone number in it. I mask the phone number when user is typing so that in shouldChangeCharactersIn function;

  • I get user input (string)
  • Add that input to text which is already written in UITextField
  • Mask text and set it to UITextField
  • Return false

My question is that after I set text of UITextfield (delete or add new character UITextField, cursor moves to the end but I want it to stay in the same position. (By meaning same position, I mean same when I don’t implement shouldChangeCharactersIn function) How can I do that? Thank you.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

    guard CharacterSet(charactersIn: "0123456789").isSuperset(of: CharacterSet(charactersIn: string)) else {
        return false
    }
    if let text = textField.text {
        let newLength = text.count + string.count - range.length
        let newText = text + string

        let textFieldText: NSString = (textField.text ?? "") as NSString
        let txtAfterUpdate = textFieldText.replacingCharacters(in: range, with: string)
            if(newLength <= 15){
                //textField.text = txtAfterUpdate
                textField.text = txtAfterUpdate.digits.applyPatternOnNumbers()
                return false
            }
            return newLength <= 15
    }
    return true
}

Mask Function:

func applyPatternOnNumbers(pattern: String = "(###) ### ## ##", replacmentCharacter: Character = "#") -> String {
var pureNumber = self.replacingOccurrences( of: "[^0-9]", with: "", options: .regularExpression)
for index in 0 ..< pattern.count {
    guard index < pureNumber.count else { return pureNumber }
    let stringIndex = String.Index(encodedOffset: index)
    let patternCharacter = pattern[stringIndex]
    guard patternCharacter != replacmentCharacter else { continue }
    pureNumber.insert(patternCharacter, at: stringIndex)
}
return pureNumber
}

What I want in GIF

enter image description here


Get this bounty!!!

#StackBounty: #swift #ios #cocoa-touch Presenting and passing data to a modal view controller without using prepare(for:sender:) method

Bounty: 50

I am using a toolbar button to present a modal view controller (in which I let the user export data as a PDF file). The main section of my app is a UITableViewController subclass embedded in a UINavigationController.

Here is a schematic of my layout.

Schematic of my storyboard layout

The modal itself is embedded in a UINavigationController as I need it to have a bottom toolbar. It also has a transparent background and is presented using .overCurrentContext, so the main screen of the user’s data blurs underneath.

I found that to get it to float over everything else (including the navigation bar etc), I had to present it from the UINavigationController (otherwise the main navigation bar and toolbar appeared on top of it).
The problem with this is that the UITableViewController method prepare(for:sender:) is not called.

I call the segue to the modal view controller like this (from the UITableViewController subclass):

// User taps EXPORT button
@objc func exportButtonTapped(_ sender: UIBarButtonItem) {
    self.navigationController?.performSegue(withIdentifier: "showExport", sender: nil)
}

In order to transfer the array of user data to the modal view controller, I have called the following code in the modal view controller:

override func viewDidLoad() {
    super.viewDidLoad()
    // Get data from array in main table view controller
    let masterNav = navigationController?.presentingViewController as! UINavigationController
    let myTableVC = masterNav.topViewController as! MyTableViewController
    self.userData = myTableVC.userData // This is of type: [MyObject]
} 

The data is then rendered to a PDF (using HTML templating) in the modal view controller’s viewWillAppear() method. This works as expected.

However, I have some concerns about doing it this way:

  1. Is it guaranteed that viewDidLoad() will finish before viewWillAppear() is called? Will an even a larger data set be available for rendering as PDF in viewWillAppear()?
  2. Is it acceptable to present modally from the UINavigationController?
  3. Should I be subclassing the main UINavigationController and using its prepare(for:sender:) method (if this is even an option)?
  4. In the performSegue(withIdentifier:sender:) method, does the sender argument make any difference?
  5. Is it preferable to use present() rather than a segue?

I would of course be grateful for any other advice or refinements to the code. It seems to work as expected; I just want to make sure I won’t run into problems in the future. Thank you.


Get this bounty!!!

#StackBounty: #ios #swift PDFKit Swapping Content of Annotation

Bounty: 150

Can the text of a FreeText annotation be changed in PDFKit without deleting an annotation / building a new annotation?

The following snippet does not change an annotation’s contents when viewing in a PDFView:

let url = Bundle.main.url(forResource: "Test", withExtension: "pdf")!
let document = PDFDocument(url: url)!

for index in 0..<document.pageCount {
    let page: PDFPage = document.page(at: index)!
    let annotations = page.annotations
    for annotation in annotations {
        annotation.contents = "[REPLACED]"
    }
}

This works – but requires replacing an annotation (and thus having to copy over all the other details of the annotation):

let url = Bundle.main.url(forResource: "Test", withExtension: "pdf")!
let document = PDFDocument(url: url)!

for index in 0..<document.pageCount {
    let page: PDFPage = document.page(at: index)!
    let annotations = page.annotations
    for annotation in annotations {
        print(annotation)
        page.removeAnnotation(annotation)
        let replacement = PDFAnnotation(bounds: annotation.bounds,
                                        forType: .freeText,
                                        withProperties: nil)

        replacement.contents = "[REPLACED]"
        page.addAnnotation(replacement)
    }
}


Get this bounty!!!

#StackBounty: #ios #swift swift: How to create UIPageViewController?

Bounty: 50

I want to create UIPageViewController with spine mid location. I read several tutorials with UIPageViewController but in this tutorials used spine min or max location. And I can not create UIPageViewController with spine mid location.

I have this function to create UIPageViewController with spine mid location min or max location:

func createPageViewController() {

            // Instantiate the PageViewController
            let pageController = self.storyboard?.instantiateViewController(withIdentifier: "PageViewController") as! UIPageViewController
            pageController.dataSource = self
            pageController.delegate = self

            if images.count > 0{
                let contentController = getContentViewController(withIndex: 0)!
                let contentControllers = [contentController]

                pageController.setViewControllers(contentControllers, direction: UIPageViewControllerNavigationDirection.forward, animated: true, completion: nil)

            }

            pageViewController = pageController

            self.addChildViewController(pageViewController!)

            self.view.addSubview(pageViewController!.view)
            pageViewController!.didMove(toParentViewController: self)

        }

I tried to change it for this:

let contentController = getContentViewController(withIndex: 0)!
let contentController1 = getContentViewController(withIndex: 1)!
let contentControllers = [contentController, contentController1]

But in this case my images not showing in pages. It is not help. What am I doing wrong? How to create UIPageViewController with spine mid location?

Update

import UIKit

class PageViewController: UIViewController {

    @IBOutlet weak var PageControl: UIPageControl!

    var pageViewController: UIPageViewController?
    var images = ["book1page1.png","book1","book1","book1page2.png","book1page1.png","book1page2.png"]
    var pendingIndex: Int?

    override func viewDidLoad() {
        super.viewDidLoad()

        createPageViewController()
        setupPageControll()

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    func createPageViewController() {
        // Instantiate the PageViewController
        let pageController = self.storyboard?.instantiateViewController(withIdentifier: "PageViewController") as! UIPageViewController
        pageController.dataSource = self
        pageController.delegate = self

        if images.count > 0{
            let firstController = getContentViewController(withIndex: 0)!
            let contentControllers = [firstController]

            pageController.setViewControllers(contentControllers, direction: UIPageViewControllerNavigationDirection.forward, animated: true, completion: nil)

        }

        pageViewController = pageController

        self.addChildViewController(pageViewController!)

        //self.view.addSubview(pageViewController!.view)
        self.view.insertSubview(pageViewController!.view, at: 0)
        pageViewController!.didMove(toParentViewController: self)

    }

    //Setup Pagination Icons and count
    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return images.count
    }

    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        return 0
    }

    func setupPageControll(){
        let apperance = UIPageControl.appearance()
        apperance.pageIndicatorTintColor = UIColor.gray
        apperance.currentPageIndicatorTintColor = UIColor.white
        apperance.backgroundColor = UIColor.clear
    }

    func currentControllerIndex() -> Int{
        let pageItemController = self.currentConroller()

        if let controller = pageItemController as? ContentViewController {
            return controller.itemIndex
        }
        return -1
    }

    ///////////////////////////////////////////////

    func currentConroller() -> UIViewController?{
        if (self.pageViewController?.viewControllers?.count)! > 0{
            return self.pageViewController?.viewControllers![0]
        }

        return nil
    }

    func getContentViewController(withIndex index: Int) -> ContentViewController? {
        if index < images.count{
            let contentVC = self.storyboard?.instantiateViewController(withIdentifier: "ContentViewController") as! ContentViewController
            contentVC.itemIndex = index
            contentVC.imageName = images[index]

            return contentVC
        }

        return nil
    }

}

extension PageViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
        pendingIndex = (pendingViewControllers.first as! ContentViewController).itemIndex
    }

    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if completed {
            let currentIndex = pendingIndex
            if let index = currentIndex {
                self.PageControl.currentPage = index
            }

        }
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let contentVC = viewController as! ContentViewController
        if contentVC.itemIndex > 0 {
            return getContentViewController(withIndex: contentVC.itemIndex - 1)
        }

        return nil
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let contentVC = viewController as! ContentViewController
        if contentVC.itemIndex + 1 < images.count {
            return getContentViewController(withIndex: contentVC.itemIndex + 1)
        }

        return nil
    }
}


Get this bounty!!!

#StackBounty: #swift #generics #core-data #protocols #swift-protocols Populating objects from cloud records (or another external source…

Bounty: 150

I’m building a generic API for my Swift applications. I use CoreData for local storage and CloudKit for cloud synchronization.

in order to be able to work with my data objects in generic functions I have organized them as follows (brief summary):

  • Objects that go in the CoreData Database are NSManagedObject instances that conform to a protocol called ManagedObjectProtocol, which enables conversion to DataObject instances
  • NSManagedObjects that need to be cloud synced conform to a protocol called CloudObject which allows populating objects from records and vice-versa
  • Objects I use in the graphic layer of my apps are NSObject classes that conform to the DataObject protocol which allows for conversion to NSManagedObject instances

an object of a specific class. What I would like this code to look like is this:

for record in records {
    let context = self.persistentContainer.newBackgroundContext()
    //classForEntityName is a function in a custom extension that returns an NSManagedObject for the entityName provided. 
    //I assume here that recordType == entityName
    if let managed = self.persistentContainer.classForEntityName(record!.recordType) {
        if let cloud = managed as? CloudObject {   
            cloud.populateManagedObject(from: record!, in: context)
        }
    }
 }

However, this gives me several errors:

Protocol 'CloudObject' can only be used as a generic constraint because it has Self or associated type requirements
Member 'populateManagedObject' cannot be used on value of protocol type 'CloudObject'; use a generic constraint instead

The CloudObject protocol looks as follows:

protocol CloudObject {
    associatedtype CloudManagedObject: NSManagedObject, ManagedObjectProtocol

    var recordID: CKRecordID? { get }
    var recordType: String { get }

    func populateManagedObject(from record: CKRecord, in context: NSManagedObjectContext) -> Promise<CloudManagedObject>
    func populateCKRecord() -> CKRecord
}

Somehow I need to find a way that allows me to get the specific class conforming to CloudObject based on the recordType I receive. How would I Best go about this?

Any help would be much appreciated!


Get this bounty!!!

#StackBounty: #swift How do you make a method appear in the "overload set" only if another method can be called?

Bounty: 100

Consider an extension on NotificationCenter which does special things for notification objects that conform to certain protocols (yes these could be more common but it’s an example to help demonstrate my real question).

extension NotificationCenter {

  func addObserver<Note: BasicNotification>(using block: @escaping (Note) -> ())
  -> NotificationToken {
    let observer = addObserver(forName: Note.notificationName, object: nil,
        queue: nil, using: { note in
      block(Note(notification: note))
    })
    return NotificationToken(observer: observer, center: self)
  }

  func addObserver<Note: CustomNotification>(using block: @escaping (Note) -> ())
  -> NotificationToken {
    let observer = addObserver(forName: Note.notificationName, object: nil,
        queue: nil, using: { note in
      block(note.object as! Note)
    })
    return NotificationToken(observer: observer, center: self)
  }

}

Now, consider when we want this notification to only fire once, and then deregister…

extension NotificationCenter {

  func observeOnce<Note: BasicNotification>(using block: @escaping (Note) -> ()) {
    var token: NotificationToken!
    token = addObserver(using: { (note: Note) in
      block(note)
      token.reset()
    })
  }

  func observeOnce<Note: CustomNotification>(using block: @escaping (Note) -> ()) {
    var token: NotificationToken!
    token = addObserver(using: { (note: Note) in
      block(note)
      token.reset()
    })
  }

}

This is the exact same code. What I really want is one observeOnce method – I don’t want to have to write two of them.

If I don’t use any conditional conformance…

  func observeOnce<Note>(using block: @escaping (Note) -> ()) {
    var token: NotificationToken!
    token = addObserver(using: { (note: Note) in
      block(note)
      token.reset()
    })
  }

I get an error Cannot invoke 'addObserver' with an argument list of type '(using: (Note) -> ())' which makes perfect sense.

If I use a common base protocol (which both conform to)…

  func observeOnce<Note: SomeNotification>(using block: @escaping (Note) -> ()) {
    var token: NotificationToken!
    token = addObserver(using: { (note: Note) in
      block(note)
      token.reset()
    })
  }

I get the exact same error, which does not quite make as much sense – I would expect something about the call being ambiguous rather than not existing at all.

Having seen the & operator mean conformance to multiple protocols, I did try using BasicNotification | CommonNotification in the extremely unlikely case where that may have some meaning… but of course it did not work.

I’v also tried a bunch of other alternatives, to no avail. What I am trying to do is have observeOnce be available to call if either of the others are available to call.

In C++, I would do something like this (didn’t run it through a compiler – hopefully you get what I’m trying to do)…

template <typename T>
auto observeOnce(std::function<void (T)> block)
-> decltype(void(this->addObserver(std::move(block))))
{
  // do my stuff here
}

The above code basically means that the function observeOnce only appears in the overload set if addObserver(std::move(block)) can be called.

So, what is the swift way of accomplishing the same thing?


Get this bounty!!!

#StackBounty: #ios #swift #nsattributedstring #core-text Why does this call to NSMutableAttributedString addAttributes crash?

Bounty: 100

I’m getting a frequent crash on calling addAttributes out in the field that I can’t reproduce (EXC_BREAKPOINT). Have added checks on the NSRange I’m passing in and surprisingly it still crashes… any ideas very gratefully received!

let boldAttributes : [NSAttributedStringKey: Any] = [.font: cellStyle.boldFont()]
if (nsrange.location >= 0 && nsrange.length > 0 && nsrange.location + nsrange.length <= myAttributedString.length) {
    myAttributedString.addAttributes(boldAttributes, range: nsrange)
}

Crash log added by request:

#0. Crashed: com.apple.main-thread
0  MyApp                   0x10054ae38 specialized UIAccessorizedTextField.accessoryAttributedString(IndexPath, cellStyle : UIAccessorizedTextFieldCellStyle) -> NSAttributedString (UIAccessorizedTextField.swift:322)
1  MyApp                   0x10054b104 specialized UIAccessorizedTextField.collectionView(UICollectionView, cellForItemAt : IndexPath) -> UICollectionViewCell (UIAccessorizedTextField.swift:345)
2  MyApp                   0x10054860c @objc UIAccessorizedTextField.collectionView(UICollectionView, cellForItemAt : IndexPath) -> UICollectionViewCell (UIAccessorizedTextField.swift)
3  UIKit                          0x18b229f3c -[UICollectionView _createPreparedCellForItemAtIndexPath:withLayoutAttributes:applyAttributes:isFocused:notify:] + 356
4  UIKit                          0x18bb90b68 -[UICollectionView _prefetchItemsForVelocity:maxItemsToPrefetch:invalidateCandidatesOnDirectionChanges:] + 508
5  UIKit                          0x18b2088b4 -[UICollectionView layoutSubviews] + 760
6  UIKit                          0x18b0d76f4 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1420
7  QuartzCore                     0x18564dfec -[CALayer layoutSublayers] + 184
8  QuartzCore                     0x18565217c CA::Layer::layout_if_needed(CA::Transaction*) + 324
9  QuartzCore                     0x1855be830 CA::Context::commit_transaction(CA::Transaction*) + 320
10 QuartzCore                     0x1855e6364 CA::Transaction::commit() + 580
11 QuartzCore                     0x1855e71e4 CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*) + 92
12 CoreFoundation                 0x18146e910 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
13 CoreFoundation                 0x18146c238 __CFRunLoopDoObservers + 412
14 CoreFoundation                 0x18146c884 __CFRunLoopRun + 1436
15 CoreFoundation                 0x18138cda8 CFRunLoopRunSpecific + 552
16 GraphicsServices               0x183371020 GSEventRunModal + 100
17 UIKit                          0x18b3a9758 UIApplicationMain + 236
18 MyApp                   0x1003f2830 main (main.m:16)
19 libdyld.dylib                  0x180e1dfc0 start + 4


Get this bounty!!!

#StackBounty: #ios #swift #xcode #avfoundation #avplayer AVPlayerLayer in UICollectionViewCell, or how to load gifs as WhatsApp

Bounty: 50

In the app I have a UICollectionView with item size so that about ~20 items are visible on the screen at the same time. The content that I want to display in each cell is Gif image downloaded from Giphy / Tenor.

However, I realized that gif files take much more space (and time to load) than the relative mp4 files that both Tenor and Giphy provide for each animated image, which is actually obvious, cause mp4 file format has a compressing logic and stuff like that. Sorry if I use wrong terms.

In order to have list loaded faster I decided to switch using UIImageView with GIF images to AVPlayerLayer, cause mp4 file is like ~10x lighter than GIF image. But I faced with the performance issue similar to what described HERE. The flow is mostly the same, I have 20+ items visible at the same time, but because of hardware limitations it only shows 16 videos. I couldn’t find any workaround or any other frameworks that would allow to have more than 16 AVPlayerLayer showing video at the same time.

I’m wondering how WhatsApp application works and handles this logic. It also has GIF selection from Tenor. I already checked and figured out that WhatsApp downloads small video files and not gif images. That’s why it loads very fast. But I have no idea how they can show 20+ items at the same time. HERE is how that works in WhatsApp – https://media.giphy.com/media/33E84h3RAVn0vQWZak/giphy.gif. Also, I notices during scroll the small static previews are showing, but I don’t see the app making requests for it. Probably they gets a first frame of the gif on the fly without any delays in main thread.

I also tried that, but even if I make every single stuff in background thread and the only line on the main thread is “self.imageView.image = myImage”, it anyway is lugging a little bit if I have 8 items in the row for example and scrolling very fast.

I see only 2 possible solutions to have it loads fast (so we definitely need to load mp4 instead of gifs), and scroll smooth and without lugs:
1. WhatsApp uses its own custom Video Core to display video in the UICollectionViewCell .
2. WhatsApp downloads video to speed up the download process but then encodes mp4 file to gif one on the fly and use regular animated UIImageView to show the output gif file. However I was not able to have this flow working very fast without lugging during ‘massive’ scrolling

Any thoughts on how to implement the same to make it works fast and smooth as in WhatsApp? I’m unable to check how it handles the downloaded info, but for sure it downloads mp4 files and not gif ones.


Get this bounty!!!

#StackBounty: #swift #ios #user-interface Page and center UICollectionView like App Store

Bounty: 50

I need a collection view to page through cells and center it like the App Store, where a portion of the previous and next cells look like this:

enter image description here

The native isPagingEnabled flag would be great if it had an option to center on the cell. However, making the cell span the full width of the collection view doesn’t show previous/next cells, and making the cell width smaller doesn’t snap to center when paging.

There are tons of hacks, articles, and suggestions out there that I’ve tried. Many were overcomplicated or a horrible UX. I finally find a good balance with this example and adjusted the code.

This is the code and scroll delegate event to make this work:

class HomeViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!
    private var indexOfCellBeforeDragging = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        let layout = UICollectionViewFlowLayout()
        let width = view.frame.width
        let height: CGFloat = 275
        let inset: CGFloat = 30
        layout.itemSize = CGSize(width: width - inset * 2, height: height)
        layout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        layout.minimumLineSpacing = 0
        layout.scrollDirection = .horizontal
        collectionView.collectionViewLayout = layout
        collectionView.frame = CGRect(x: 0, y: 0, width: width, height: height)
    }

    ...
}

extension HomeViewController: UICollectionViewDelegate {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        indexOfCellBeforeDragging = indexOfMajorCell(for: collectionView)
    }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }

        // Stop scrollView sliding
        targetContentOffset.pointee = scrollView.contentOffset

        // Calculate where scrollView should snap to
        let indexOfMajorCell = self.indexOfMajorCell(for: collectionView)

        // Calculate conditions
        let dataSourceCount = collectionView(collectionView!, numberOfItemsInSection: 0)
        let swipeVelocityThreshold: CGFloat = 0.5 // After some trail and error
        let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSourceCount && velocity.x > swipeVelocityThreshold
        let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold
        let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging
        let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging
            && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)

        guard didUseSwipeToSkipCell else {
            // Better way to scroll to a cell
            return collectionView.scrollToItem(
                at: IndexPath(row: indexOfMajorCell, section: 0),
                at: .centeredHorizontally,
                animated: true
            )
        }

        let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
        let toValue = layout.itemSize.width * CGFloat(snapToIndex)

        // Damping equal 1 => no oscillations => decay animation
        UIView.animate(
            withDuration: 0.3,
            delay: 0,
            usingSpringWithDamping: 1,
            initialSpringVelocity: velocity.x,
            options: .allowUserInteraction,
            animations: {
                scrollView.contentOffset = CGPoint(x: toValue, y: 0)
                scrollView.layoutIfNeeded()
            },
            completion: nil
        )
    }

    private func indexOfMajorCell(for collectionView: UICollectionView) -> Int {
        guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return 0 }
        let itemWidth = layout.itemSize.width
        let proportionalOffset = collectionView.contentOffset.x / itemWidth
        return Int(round(proportionalOffset))
    }
}

Do you have any suggestions or advice on how to simplify this or make it reusable? I don’t like how everything is not encapsulated in the extension (i.e., the private property indexOfCellBeforeDragging is outside). Also, any issues that come to mind?

UPDATE:

I found a better way to encapsulate by subclassing a UICollectionViewFlowLayout:

class HomeViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let width = view.frame.width
        let height: CGFloat = 275
        collectionView.collectionViewLayout = SnapPagingLayout(width: width, height: height, inset: 30)
        collectionView.frame = CGRect(x: 0, y: 0, width: width, height: height + 40)
    }

    ...
}

extension HomeViewController: UICollectionViewDelegate {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        guard let layout = collectionView.collectionViewLayout as? SnapPagingLayout else { return }
        layout.willBeginDragging()
    }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let layout = collectionView.collectionViewLayout as? SnapPagingLayout else { return }
        layout.willEndDragging(withVelocity: velocity, targetContentOffset: targetContentOffset)
    }

}

class SnapPagingLayout: UICollectionViewFlowLayout {
    private var indexOfCellBeforeDragging = 0

    convenience init(width: CGFloat, height: CGFloat, inset: CGFloat) {
        self.init()

        self.itemSize = CGSize(width: width - inset * 2, height: height)
        self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        self.minimumLineSpacing = 0
        self.scrollDirection = .horizontal
    }
}

private extension SnapPagingLayout {

    func indexOfMajorCell() -> Int {
        guard let collectionView = collectionView else { return 0 }
        let proportionalOffset = collectionView.contentOffset.x / itemSize.width
        return Int(round(proportionalOffset))
    }
}

extension SnapPagingLayout {

    func willBeginDragging() {
        indexOfCellBeforeDragging = indexOfMajorCell()
    }

    func willEndDragging(withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let collectionView = collectionView else { return }

        // Stop scrollView sliding
        targetContentOffset.pointee = collectionView.contentOffset

        // Calculate where scrollView should snap to
        let indexOfMajorCell = self.indexOfMajorCell()

        guard let dataSourceCount = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0),
            dataSourceCount > 0 else {
                return
        }

        // Calculate conditions
        let swipeVelocityThreshold: CGFloat = 0.5 // After some trail and error
        let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSourceCount && velocity.x > swipeVelocityThreshold
        let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold
        let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging
        let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging
            && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)

        guard didUseSwipeToSkipCell else {
            // Better way to scroll to a cell
            return collectionView.scrollToItem(
                at: IndexPath(row: indexOfMajorCell, section: 0),
                at: .centeredHorizontally,
                animated: true
            )
        }

        let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
        let toValue = itemSize.width * CGFloat(snapToIndex)

        // Damping equal 1 => no oscillations => decay animation
        UIView.animate(
            withDuration: 0.3,
            delay: 0,
            usingSpringWithDamping: 1,
            initialSpringVelocity: velocity.x,
            options: .allowUserInteraction,
            animations: {
                collectionView.contentOffset = CGPoint(x: toValue, y: 0)
                collectionView.layoutIfNeeded()
            },
            completion: nil
        )
    }
}

Do you have any advice or suggestions on optimizing or a better UX? It doesn’t feel as smooth as the App Store (especially when scrolling fast), but it’s almost there. Also, it misbehaves in landscape or in iPad; I think there needs to be some kind of cancellation if something like more than 1 cell can fit on the screen!


Get this bounty!!!