Nick Coelius
Nick Coelius

Reputation: 4746

Custom UITableview Cell Occasionally Overlapping/Reproducing On Top Of Other Cell

Really strange problem I'm having here.

Basically, the user comes to a View that has a TableView attached to it. There's a call to the backend for some data, and in the completion block of that data is provided to the TableView to display.

In cellForRow 1 of 4 reusable, custom cells is dequeued, based on some logic, and then a configure method for that cell is called.

Here's where it gets tricky: Some of the cells might require further asynchronous calls (to fetch more data for their custom displays), thus required a tableView.reloadRows call...What's happening, then, is best explained by these two screenshots:

Wrong Right

In the first one, that cell with "Ever wonder what YOU'D do for a klondike bar?" is replicated on top of the "Self-Management for Actor's" cell which, as a cell containing Twitter info DID require an additional asynch call to fetch that data.

Any thoughts? I'm running the tableView.reloadRows call inside of a begin/endUpdates block, but no dice.

cellForRow:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let postAtIndexPath = posts[indexPath.row]
    let cell: PostCell!

    if postAtIndexPath.rawURL == "twitter.com" {
        cell = tableView.dequeueReusableCell(withIdentifier: "TwitterCell", for: indexPath) as! TwitterCell
    } else if !postAtIndexPath.rawURL.isEmpty {
        cell = tableView.dequeueReusableCell(withIdentifier: "LinkPreviewCell", for: indexPath) as! LinkPreviewCell
    } else if postAtIndexPath.quotedID > -1 {
        cell = tableView.dequeueReusableCell(withIdentifier: "QuoteCell", for: indexPath) as! QuoteCell
    } else {
        cell = tableView.dequeueReusableCell(withIdentifier: "PostCell", for: indexPath) as! PostCell
    }

    cell.isUserInteractionEnabled = true

    cell.privacyDelegate = self
    cell.shareDelegate = self
    cell.likeDelegate = self
    cell.linkPreviewDelegate = self
    cell.reloadDelegate = self
    postToIndexPath[postAtIndexPath.id] = indexPath

    if parentView == "MyLikes" || parentView == "Trending" {
        cell.privatePostButton.isHidden = true
        cell.privatePostLabel.isHidden = true
    }

    cell.configureFor(post: postAtIndexPath,
                         liked: likesCache.postIsLiked(postId: postAtIndexPath.id),
                         postIdToAttributions: postIdToAttributions,
                         urlStringToOGData: urlStringToOGData,
                         imageURLStringToData: imageURLStringToData)

    cell.contentView.layoutIfNeeded()

    return cell
}

asynch Twitter call:

private func processForTwitter(og: OpenGraph, urlLessString: NSMutableAttributedString, completion:(() -> ())?) {
    let ogURL = og[.url]!
    let array = ogURL.components(separatedBy: "/")
    let client = TWTRAPIClient()
    let tweetID = array[array.count - 1]
    if let tweet = twitterCache.tweet(forID: Int(tweetID)!)  {
        for subView in containerView.subviews {
            subView.removeFromSuperview()
        }

        let tweetView = TWTRTweetView(tweet: tweet as? TWTRTweet, style: .compact)
        containerView.addSubview(tweetView)
        tweetView.translatesAutoresizingMaskIntoConstraints = false
        tweetView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
        tweetView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
        containerView.leftAnchor.constraint(equalTo: tweetView.leftAnchor).isActive = true
        containerView.rightAnchor.constraint(equalTo: tweetView.rightAnchor).isActive = true
        containerView.isHidden = false
        postText.isHidden = false

    } else {
        client.loadTweet(withID: tweetID, completion: { [weak self] (t, error) in
            if let weakSelf = self {
                if let tweet = t {
                    weakSelf.twitterCache.addTweetToCache(tweet: tweet, forID: Int(tweetID)!)
                }
            }

            if let complete = completion {
                complete()
            }
        })`enter code here`
    }

    if urlLessString.string.isEmpty {
        if let ogTitle = og[.title] {
            postText.text = String(htmlEncodedString: ogTitle)
        }
    } else {
        postText.attributedText = urlLessString
    }
}

completion block in the tableviewcontroller:

func reload(postID: Int) {

    tableView.beginUpdates()
    self.tableView.reloadRows(at: [self.postToIndexPath[postID]!], with: .automatic)
    tableView.endUpdates()

}

NB: this doesn't happen 100% of the time, it is, presumably, network dependent.

Upvotes: 5

Views: 3026

Answers (5)

Marek Manduch
Marek Manduch

Reputation: 2481

I came across a similar problem. After

    [self.tableView beginUpdates];
    [self configureDataSource]; // initialization of cells by dequeuing 
    [self.tableView insertSections:[[NSIndexSet alloc] initWithIndex:index] withRowAnimation:UITableViewRowAnimationFade];
    [self.tableView endUpdates];

and also after

    [self.tableView beginUpdates];
    [self configureDataSource]; // initialization of cells by dequeuing  
    [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:index] withRowAnimation:UITableViewRowAnimationFade];
    [self.tableView endUpdates];

two rows of my TableViewController were overlapped.

Then I found out where's the catch.

After beginUpdate and endUpdate are two methods called for cells:estimatedHeightForRowAtIndexPath and heightForRowAtIndexPath. But there's no call of cellForRowAtIndexPath.

One of overlapping cells was dequeued but I set it's text/value only in cellForRowAtIndexPath method. So cell height was computed correctly but for incorrect content.

My fix was to set text/value also after dequeuing the cell. So the only difference is inside my configureDataSource method:

    [self.tableView beginUpdates];
    [self configureDataSource]; // init of cells by dequeuing and set its texts/values  
    // delete or insert section
    [self.tableView endUpdates];

(Other possible fix is to avoid reinitialization of cells. But this caching of initialized cells wouldn't be good for TableViews with tons of cells)

Upvotes: 0

Nick Coelius
Nick Coelius

Reputation: 4746

Turns out there's weird, or at least I don't understand it, behavior around estimatedRowHeight and reloadRows; solution, via a tangentially related SO question, was to cache the row heights as they become available and return that if available in heightForRow.

I guess it doesn't recalculate the heights, or something, on a reload? Really don't understand specifically WHY this works, but I'm happy it does!

Upvotes: 1

Milan Nosáľ
Milan Nosáľ

Reputation: 19737

After the async call, instead of tableView.reloadRows try to call:

tableView.beginUpdates()
tableView.setNeedsLayout()
tableView.endUpdates()

It seems like the data get populated, but the size of the cell does not get reflected. If you call reloadRows I'm afraid it does not recalculate the new positions of the following cells - that means if cell row 3 resizes to a bigger height, it will get bigger, but the following cells won't get pushed further down.

Upvotes: 1

ibnetariq
ibnetariq

Reputation: 738

I am assuming processForTwitter is called on background/async from within configureFor. If true this is the fault. Your api call should be on back ground but if you are getting tweet from your cache than it addSubview should be done on main thread and before returning cell. Try doing this, make sure that processForTwitter is called on main thread and configure it as

   private func processForTwitter(og: OpenGraph, urlLessString: NSMutableAttributedString, completion:(() -> ())?) {
        let ogURL = og[.url]!
        let array = ogURL.components(separatedBy: "/")
        let client = TWTRAPIClient()
        let tweetID = array[array.count - 1]
        if let tweet = twitterCache.tweet(forID: Int(tweetID)!)  {
            for subView in containerView.subviews {
                subView.removeFromSuperview()
            }

            let tweetView = TWTRTweetView(tweet: tweet as? TWTRTweet, style: .compact)
            containerView.addSubview(tweetView)
            tweetView.translatesAutoresizingMaskIntoConstraints = false
            tweetView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
            tweetView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
            containerView.leftAnchor.constraint(equalTo: tweetView.leftAnchor).isActive = true
            containerView.rightAnchor.constraint(equalTo: tweetView.rightAnchor).isActive = true
            containerView.isHidden = false
            postText.isHidden = false

        } else {
    // run only this code in background/async

            client.loadTweet(withID: tweetID, completion: { [weak self] (t, error) in
                if let weakSelf = self {
                    if let tweet = t {
                        weakSelf.twitterCache.addTweetToCache(tweet: tweet, forID: Int(tweetID)!)
                    }
                }
// completion on main

                if let complete = completion {
  DispatchQueue.main.async {
 complete()

    }   
                }
            })
        }

        if urlLessString.string.isEmpty {
            if let ogTitle = og[.title] {
                postText.text = String(htmlEncodedString: ogTitle)
            }
        } else {
            postText.attributedText = urlLessString
        }
    }

Now sequence would be like this

  1. Cell is loaded for first time
  2. No tweet found in cache
  3. Async network call in processForTwitter and cell is returned without proper data
  4. Async call gets tweet. And calls completion on main thread which results in reload
  5. configure for is called again on main thread
  6. tweet is found in cache while remaining on main thread.
  7. add your TWTRTweetView (you are still on main thread) and this will be done before your cell is returned.

remember this, every component in your cell which will play role in calculating size of cell should be added on main thread before returning cell in cellForIndexPath

Upvotes: 0

nikBhosale
nikBhosale

Reputation: 541

I guess the issue lies in calculating the size of the components that are present on cell. It's like cell is dequed and frame along with subviews are set before data is available.

Maybe you can try,

tableView.rowHeight = UITableViewAutomaticDimension tableView.estimatedRowHeight = yourPrefferedHeight

but you need to make sure that your autolayout constraints are proper and cell can determine it's height if all the components has got height.

Upvotes: 1

Related Questions