CristianMoisei
CristianMoisei

Reputation: 2279

Lag of several seconds when opening ViewController from TableView

I am trying to debug a pretty difficult problem and I cannot get to the bottom of it, so I was wondering if anyone could share some suggestions.

I have a TableView that transitions to a different VC when didSelectRowAt is called, and while the tap is registered immediately, something in the background is causing the new VC to only be presented as much as 5 seconds later and I cannot figure out what is causing this.

What I tried so far: - moving the iCloud tasks to the global thread - commenting out the entirety of the iCloud functions and saving data locally - disabling the Hero pod and using a built in segue with or without an animation - commenting out the tableview.reloadData() calls - commenting out everything in viewDidAppear - running this on both iOS12 and iOS13 GM, so it's not an OS issue - profiling the app, where I couldn't see anything out of the ordinary, but then again I am not very familiar with the profiler

I apologise for the long code dump but since I'm not sure what is causing this, I want to provide as much detail as I can.

Thanks a lot for any insights you might be able to share.

The main class

import UIKit
import SPAlert
import CoreLocation
import NotificationCenter
import PullToRefreshKit


class List: UIViewController {

    // Outlets
    @IBOutlet weak var plus: UIButton!
    @IBOutlet weak var notes: UIButton!
    @IBOutlet weak var help: UIButton!
    @IBOutlet weak var tableview: UITableView!
    @IBOutlet weak var greeting: UILabel!
    @IBOutlet weak var temperature: UILabel!
    @IBOutlet weak var weatherIcon: UIImageView!
    @IBOutlet weak var weatherButton: UIButton!
    @IBOutlet weak var greetingToTableview: NSLayoutConstraint!

    let locationManager = CLLocationManager()

    @IBAction func notesTU(_ sender: Any) {
        performSegue(withIdentifier: "ToNotes", sender: nil)
    }

    @IBAction func notesTD(_ sender: Any) {
        notes.tap(shape: .square)
    }

    @IBAction func plusTU(_ sender: Any) {
        hero(destination: "SelectionScreen", type: .zoom)
    }

    @IBAction func plusTD(_ sender: Any) {
        plus.tap(shape: .square)
    }

    @IBAction func helpTU(_ sender: Any) {
        performSegue(withIdentifier: "ToHelp", sender: nil)        
    }

    @IBAction func helpTD(_ sender: Any) {
        help.tap(shape: .square)
    }

    @IBAction func weatherButtonTU(_ sender: Any) {
        performSegue(withIdentifier: "OpenModal", sender: nil)
        selectedModal = "Weather"
    }


    // Variables
    override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent }

    // MARK: viewDidLoad
    override func viewDidLoad() {
        super.viewDidLoad()
        tableview.estimatedRowHeight = 200
        tableview.rowHeight = UITableView.automaticDimension

        // Retrieves ideas from the JSON file and assings them to the ideas array
        ideas = readJSONIdeas()
        goals = readJSONGoals()
        ideaStats = readJSONIdeaStats()
        decisions = readJSONDecisions()

        let time = Calendar.current.component(.hour, from: Date())
        switch time {
            case 21...23: greeting.text = "Good Night"
            case 0...4: greeting.text = "Good Night"
            case 5...11: greeting.text = "Good Morning"
            case 12...17: greeting.text = "Good Afternoon"
            case 17...20: greeting.text = "Good Evening"
            default: print("Something went wrong with the time based greeting")
        }

        temperature.alpha = 0
        weatherIcon.alpha = 0

        getWeather(temperatureLabel: temperature, iconLabel: weatherIcon)

        NotificationCenter.default.addObserver(self, selector: #selector(self.replayOnboarding), name: Notification.Name(rawValue: "com.cristian-m.replayOnboarding"), object: nil)


        if iCloudIsOn() {
            NotificationCenter.default.addObserver(self, selector: #selector(self.reloadAfteriCloud), name: Notification.Name(rawValue: "com.cristian-m.iCloudDownloadFinished"), object: nil)

            tableview.configRefreshHeader(with: RefreshHeader(),container:self) {
                // After the user pulls to refresh, synciCloud is called and the pull to refresh view is left open.
                // synciCloud posts a notification for key "iCloudDownloadFinished" once it finishes downloading, which then calls reloadAfteriCloud()
                // reloadAfteriCloud() loads the newly downloaded files into memory, reloads the tableview and closes the refresher view
                if iCloudIsAvailable() { synciCloud() }
                else {
                    self.alert(title: "It looks like you're not signed into iCloud on this device",
                          message: "Turn on iCloud in Settings to use iCloud Sync",
                          actionTitles: ["Got it"],
                          actionTypes: [.regular],
                          actions: [nil])
                }
            }
            synciCloud()
        }



        // Responsive Rules
        increasePageInsetsBy(top: 10, left: 20, bottom: 20, right: 20, forDevice: .iPad)
        increasePageInsetsBy(top: 0, left: 0, bottom: 14, right: 0, forDevice: .iPhone8)

        greetingToTableview.resize(to: 80, forDevice: .iPad)

    }

    @objc func replayOnboarding(_ notification:NSNotification){
        DispatchQueue.main.asyncAfter(deadline: .now()+0.2) {
            self.hero(destination: "Onboarding1", type: .zoom)
        }
    }

    @objc func reloadAfteriCloud(_ notification:NSNotification){
        goals = readJSONGoals()
        ideas = readJSONIdeas()
        ideaStats = readJSONIdeaStats()
        decisions = readJSONDecisions()

        tableview.reloadData()
        self.tableview.switchRefreshHeader(to: .normal(.none, 0.0))

        setWeeklyNotification()
    }

    @objc func goalCategoryTapped(_ sender: UITapGestureRecognizer?) {
        hero(destination: "GoalStats", type: .pushLeft)
    }

    @objc func ideaCategoryTapped(_ sender: UITapGestureRecognizer?) {
        hero(destination: "IdeaStats", type: .pushLeft)
    }


    override func viewWillAppear(_ animated: Bool) {
        tableview.reloadData()

        if shouldDisplayGoalCompletedAlert == true {
            shouldDisplayGoalCompletedAlert = false
            SPAlert.present(title: "Goal Completed", preset: .done)
        }

        if CLLocationManager.locationServicesEnabled() {
            locationManager.delegate = self
            locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        }

    }

}

The tableview extension

import UIKit

extension List: UITableViewDelegate, UITableViewDataSource {

    // MARK: numberOfSections
    func numberOfSections(in tableView: UITableView) -> Int { return 3 }

    // MARK: viewForHeader
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {

        let cell = tableView.dequeueReusableCell(withIdentifier: "CategoryCell") as! CategoryCell

        switch section {
        case 0:
            cell.title.text = "Goals"

            if goals.count != 0 { cell.emptyText.text = "You have \(completedGoals.count) achieved goals" }
            else { cell.emptyText.text = "No goals added yet" }

            if activeGoals.count > 0 { cell.emptyText.removeFromSuperview() }

            break
        case 1:
            cell.title.text = "Ideas"
            cell.emptyText.text = "No ideas added yet"

            if ideas.count > 0 { cell.emptyText.removeFromSuperview() }

            break
        case 2:
            cell.title.text = "Decisions"
            cell.arrow.removeFromSuperview()

            cell.emptyText.text = "No decisions added yet"
            if decisions.count > 0 { cell.emptyText.removeFromSuperview() }

            break
        default: print("Something went wrong with the section Switch")
        }

        if section == 0 {
            cell.button.addTarget(self, action: #selector(goalCategoryTapped(_:)), for: .touchUpInside)
        } else if section == 1 {
            cell.button.addTarget(self, action: #selector(ideaCategoryTapped(_:)), for: .touchUpInside)
        }

        return cell.contentView
    }

    // MARK: heightForHeader
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {

        var cellHeight = CGFloat(60)

        if (activeGoals.count > 0 && section == 0) || (ideas.count > 0 && section == 1) || (decisions.count > 0 && section == 2) {
            cellHeight = CGFloat(40)
        }

        return cellHeight
    }

    // MARK: numberOfRows
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        var numberOfRows: Int = 0

        if section == 0 { numberOfRows = activeGoals.count }
        if section == 1 { numberOfRows = ideas.count }
        if section == 2 { numberOfRows = decisions.count }

        return numberOfRows
    }

    // cellForRowAt
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        if indexPath.section == 0 {
            // Goal Cell
            let cell = tableview.dequeueReusableCell(withIdentifier: "GoalCell", for: indexPath) as! GoalCell

            cell.goalTitle?.text = activeGoals[indexPath.row].title

            if activeGoals[indexPath.row].steps!.count == 1 {
                cell.goalNoOfSteps?.text = "\(activeGoals[indexPath.row].steps?.count ?? 0) Step"
            } else if activeGoals[indexPath.row].steps!.count > 0 {
                cell.goalNoOfSteps?.text = "\(activeGoals[indexPath.row].steps?.count ?? 0) Steps"
            } else {
                cell.goalNoOfSteps?.text = "No more steps"
            }

            if goals[indexPath.row].stringDate != "I'm not sure yet" {
                cell.goalDuration.text = goals[indexPath.row].timeLeft(from: Date())
            } else {
                cell.goalDuration.text = ""
            }

            cell.selectionStyle = .none

            cell.background.hero.id = "goal\(realIndexFor(activeGoalAt: indexPath))"

            // Progress Bar
            cell.progressBar.configure(goalsIndex: realIndexFor(activeGoalAt: indexPath))

            return cell

        } else if indexPath.section == 1 {
            // Idea Cell
            let cell = tableView.dequeueReusableCell(withIdentifier: "IdeaCell", for: indexPath) as! IdeaCell

            cell.ideaTitle.text = ideas[indexPath.row].title
            if cell.ideaDescription != nil {
                cell.ideaDescription.text = String(ideas[indexPath.row].description!.filter { !"\n\t".contains($0) })

                if cell.ideaDescription.text == "Notes" || cell.ideaDescription.text == "" || cell.ideaDescription.text == " " || cell.ideaDescription.text == ideaPlaceholder {
                    cell.ideaDescriptionHeight.constant = 0
                    cell.bottomConstraint.constant = 16
                } else {
                    cell.ideaDescriptionHeight.constant = 38.6
                    cell.bottomConstraint.constant = 22
                }
            }

            cell.background.hero.id = "idea\(indexPath.row)"

            let image = UIImageView(image: UIImage(named: "delete-accessory"))
            image.contentMode = .scaleAspectFit
            cell.selectionStyle = .none

            return cell
        } else {
            // Decision Cell
            let cell = tableView.dequeueReusableCell(withIdentifier: "DecisionCell", for: indexPath) as! DecisionCell
            cell.title.text = decisions[indexPath.row].title

            let image = UIImageView(image: UIImage(named: "delete-accessory"))
            image.contentMode = .scaleAspectFit
            cell.selectionStyle = .none

            return cell
        }

    }

    // MARK: didSelectRowAt
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if indexPath.section == 0 {
            selectedCell = realIndexFor(activeGoalAt: indexPath)
            performSegue(withIdentifier: "toGoalDetails", sender: nil)
        } else if indexPath.section == 1 {
            selectedCell = indexPath.row
            performSegue(withIdentifier: "toIdeaDetails", sender: nil)
        } else {
            selectedDecision = indexPath.row
            hero(destination: "DecisionDetails", type: .zoom)
        }
        print("tap")
    }

    // MARK: viewForFooter
    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        let cell = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 10))
        cell.backgroundColor = UIColor(named: "Dark")
        return cell
    }

    // MARK: heightForFooter
    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        let height:CGFloat = 18
        return height
    }

    // MARK: canEditRowAt
    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true }

    // MARK: trailingSwipeActions
    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

        let action = UIContextualAction(style: .normal, title: nil, handler: { (action,view,completionHandler ) in

            var message = "This will delete this goal and all its steps permanently"
            if indexPath.section == 1 { message = "This will delete this idea permanently" }

            self.alert(title: "Are you sure?",
                       message: message,
                       actionTitles: ["No, cancel", "Yes, delete"],
                       actionTypes: [.regular, .destructive],
                       actions: [ nil, { action1 in
                            tableView.beginUpdates()
                            switch indexPath.section {
                            case 0:
                                deleteGoal(at: realIndexFor(activeGoalAt: indexPath))
                                tableView.deleteRows(at: [indexPath], with: .fade)
                            case 1:
                                deleteIdea(at: indexPath.row)
                                tableView.deleteRows(at: [indexPath], with: .fade)
                            case 2:
                                deleteDecision(at: indexPath.row)
                                tableView.deleteRows(at: [indexPath], with: .fade)
                            default: break
                            }
                            tableView.endUpdates()
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
                                tableView.reloadData()
                            })
                        },
                ]
            )
            completionHandler(true)
        })

        action.image = UIImage(named: "delete-accessory")
        action.backgroundColor = UIColor(named: "Dark")
        let confrigation = UISwipeActionsConfiguration(actions: [action])
        confrigation.performsFirstActionWithFullSwipe = false

        return confrigation
    }

}

The VC that gets opened

import UIKit

class GoalDetails: UIViewController {

    // MARK: Variables
    var descriptionExpanded = false
    var descriptionExists = true
    var keyboardHeight = CGFloat(0)
    override var preferredStatusBarStyle: UIStatusBarStyle { if #available(iOS 13.0, *) { return .darkContent } else { return .default } }


    // MARK: Outlets
    @IBOutlet weak var background: UIView!
    @IBOutlet weak var steps: UILabel!
    @IBOutlet weak var detailsTitle: UILabel!
    @IBOutlet weak var detailsDescription: UILabel!
    @IBOutlet weak var tableview: UITableView!
    @IBOutlet weak var progressBar: UIProgressView!
    @IBOutlet weak var plusButton: UIButton!
    @IBOutlet var descriptionHeight: NSLayoutConstraint!
    @IBOutlet weak var completeGoalButton: UIButton!
    @IBOutlet weak var completeGoalButtonHeight: NSLayoutConstraint!
    @IBOutlet weak var progressBarHeight: NSLayoutConstraint!
    @IBOutlet weak var dismissButton: UIButton!
    @IBOutlet weak var editButton: UIButton!
    @IBOutlet weak var tableviewBottomConstraint: NSLayoutConstraint!
    @IBOutlet weak var topToContainer: NSLayoutConstraint!
    @IBOutlet weak var bottomToContainer: NSLayoutConstraint!
    @IBOutlet weak var rightToContainer: NSLayoutConstraint!
    @IBOutlet weak var leftToContainer: NSLayoutConstraint!
    @IBOutlet weak var leftToTableview: NSLayoutConstraint!
    @IBOutlet weak var rightToTableview: NSLayoutConstraint!
    @IBOutlet weak var leftToEdit: NSLayoutConstraint!
    @IBOutlet weak var rightToPlus: NSLayoutConstraint!


    // MARK: Outlet Functions
    @IBAction func completeThisGoal(_ sender: Any) {
        shouldDisplayGoalCompletedAlert = true
        goals[selectedCell].completed = true
        goals[selectedCell].dateAchieved = Date()
        activeGoals = goals.filter { $0.completed == false }
        completedGoals = goals.filter { $0.completed == true }
        writeJSONGoals()
        hero.dismissViewController()

        setWeeklyNotification()
    }

    @IBAction func descriptionButtonTU(_ sender: Any) {
        if descriptionExpanded == false {
            descriptionHeight.isActive = false
            descriptionExpanded = true
        } else {
            descriptionHeight.isActive = true
            descriptionExpanded = false
        }
    }

    @IBAction func swipeDown(_ sender: Any) {
        dismissButton.tap(shape: .square)
        hero.dismissViewController()
    }


    @IBAction func dismissTU(_ sender: Any) {
        hero.dismissViewController()
    }

    @IBAction func dismissTD(_ sender: Any) {
        dismissButton.tap(shape: .square)
    }

    @IBAction func plusTU(_ sender: Any) {

        goals[selectedCell].steps?.append(Step(title: ""))

        let numberOfCells = tableview.numberOfRows(inSection: 0)

        tableview.reloadData()
        tableview.layoutIfNeeded()

        DispatchQueue.main.asyncAfter(deadline: .now()+0.2) {
            let cell = self.tableview.cellForRow(at: IndexPath.init(row: numberOfCells, section: 0)) as? StepCell
            cell?.label.becomeFirstResponder()
        }

        let indexPath = IndexPath(row: goals[selectedCell].steps!.count - 1, section: 0)
        tableview.scrollToRow(at: indexPath, at: .bottom, animated: true)

        progressBar.configure(goalsIndex: selectedCell)

        configureCompleteGoalButton(buttonHeight: completeGoalButtonHeight, progressBarHeight: progressBarHeight, progressBar: progressBar)

        updateNumberofSteps()
    }

    @IBAction func plusTD(_ sender: Any) {
        plusButton.tap(shape: .square)
    }


    @IBAction func editTU(_ sender: Any) {
        performSegue(withIdentifier: "ToGoalEdit", sender: nil)
    }

    @IBAction func editTD(_ sender: Any) {
        editButton.tap(shape: .rectangle)
    }


    // MARK: Class Functions
    func updateNumberofSteps(){
        if goals[selectedCell].steps!.count > 0 {
            steps.text = "\(goals[selectedCell].steps?.count ?? 0) Steps"
        } else {
            steps.text = "No more steps"
        }
    }


    // MARK: viewDidLoad
    override func viewDidLoad() {

        background.hero.id = "goal\(selectedCell)"

        self.background.clipsToBounds = true
        background.layer.cornerRadius = 16
        background.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]

        updateNumberofSteps()

        // Progress Bar
        progressBar.configure(goalsIndex: selectedCell)

        tableview.emptyDataSetSource = self
        tableview.emptyDataSetDelegate = self

        configureCompleteGoalButton(buttonHeight: completeGoalButtonHeight, progressBarHeight: progressBarHeight, progressBar: progressBar)

        // Responsive Rules
        increasePageInsetsBy(top: 0, left: 0, bottom: 14, right: 0, forDevice: .iPad)
        if UIDevice.current.userInterfaceIdiom == .pad {
            detailsTitle.font = UIFont.boldSystemFont(ofSize: 30)
            topToContainer.constant = 20
            leftToContainer.constant = 40
            rightToContainer.constant = 40
            bottomToContainer.constant = 40

            leftToTableview.constant = 40
            rightToTableview.constant = 40
            leftToEdit.constant = 40
            rightToPlus.constant = 30
        }

        increasePageInsetsBy(top: 0, left: 0, bottom: 12, right: 0, forDevice: .iPhone8)
    }


    // MARK: viewWillAppear
    override func viewWillAppear(_ animated: Bool) {
        // Deleting a goal from the Edit page seems to also call ViewWillAppear, which causes the app to crash unless checking whether the index exists anymore
        // selectedCell already get assigned the real index of this goal
        if goals.indices.contains(selectedCell) {

            detailsTitle.text = goals[selectedCell].title
            detailsDescription.text = goals[selectedCell].description

            if goals[selectedCell].description == "Reason" || goals[selectedCell].description == "" {
                descriptionHeight.constant = 0
            } else {
                descriptionHeight.constant = 58
            }
        }
    }

}

The iCloud related functions being called

func iCloudIsAvailable() -> Bool {
    // This function checks whether iCloud is available on the device
    if FileManager.default.ubiquityIdentityToken != nil { return true }
    else { return false }
}

func iCloudIsOn() -> Bool {
    // This function checks whether the user chose to use iCloud with Thrive
    if UserDefaults.standard.url(forKey: "UDDocumentsPath")! == iCloudPath || UserDefaults.standard.url(forKey: "UDDocumentsPath") == iCloudPath {
        return true
    }
    else {
        return false
    }
}

func synciCloud(){
    if iCloudIsAvailable() {
            do { try FileManager.default.startDownloadingUbiquitousItem(at: UserDefaults.standard.url(forKey: "UDDocumentsPath")!)
                do {
                    let status = try UserDefaults.standard.url(forKey: "UDDocumentsPath")!.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])

                    while status.ubiquitousItemDownloadingStatus != .current {
                        DispatchQueue.global().asyncAfter(deadline: .now() + 0.5, execute: {
                            print("iCloud still downloading - \(String(describing: status.ubiquitousItemDownloadingStatus))")
                        })
                    }
                    DispatchQueue.main.async {
                        NotificationCenter.default.post(name: Notification.Name(rawValue: "com.cristian-m.iCloudDownloadFinished"), object: nil)
                    }
                    print("iCloud up to date! - \(String(describing: status.ubiquitousItemDownloadingStatus))")
                }
                catch let error { print("Failed to get status: \(error.localizedDescription)") }
            }
            catch let error { print("Failed to download iCloud Documnets Folder: \(error.localizedDescription)") }
    } else {
        // TODO: Handle use case where iCloud is not available when trying to sync
        print("iCloud is not available on this device")
    }
}

Update: based on Duncan's answer, what fixed the problem was moving the three Segues I have in didSelectRowAt to the main queue like this:

DispatchQueue.main.async {
    self.performSegue(withIdentifier: "toGoalDetails", sender: nil)
}

Upvotes: 0

Views: 679

Answers (2)

Duncan C
Duncan C

Reputation: 131408

Usually very long lags between triggering UI code and having it take effect is a symptom of doing UIKit calls from a background thread. (That can lead to all kind of bad outcomes, but long delays in responsiveness is a common one.)

I don't see anything obvious from a glance at your code, but you posted a WHOLE BUNCH of code and I don't have the time to wade through it right now. I suggest setting breakpoints at various locations where you do UIKit calls and see if they break from any thread other than thread 1 (the main thread.) – Duncan C yesterday Delete

Upvotes: 2

Hong Wei
Hong Wei

Reputation: 1407

Normally if I cannot figure out what is causing the problem, I would perform "binary search" style debugging.

You mentioned that you have commented out the whole of viewDidAppear, but I am assuming you have not tried that with viewDidLoad.

In this case, I will comment out all the code in viewDidLoad, run it and see if the delay is still around.

If the delay is gone, I will comment out half the code in viewDidLoad, rerun. Generally, once I found the half that causes the delay, I would comment out half of that "bad" code and repeat until I find the exact lines causing the problem.

Upvotes: 2

Related Questions