Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I re-achieve a clear color UITableViewCell mask effect during cell dismissal on iOS 11 that previously worked on iOS 10?

The Preface

I have a working custom UITableViewCell dismissal animation on iOS 10. The way it is implemented is as follows:

  1. User presses the submit button
  2. A custom view is created, positioned and inserted at index zero of the UITableViewCell's subviews (underneath the cell's contentView)
  3. The contentView is animated out of frame while the custom view's alpha is animated to 1.0
  4. When the contentView is fully out-of-frame, the native UITableView method deleteRows(at: [indexPath], with: .top) is called. The UITableViewCell collapses and the custom view is masked behind the previous UITableViewCell as it collapses.
  5. The cell (and hence all of it's subviews including my custom view) is deleted.

Below is a slow-animation of it working:

UITableViewCell mask working on iOS 10

Note: The tableView has a clear background color, allowing a custom view (the blue) to show behind it. Each cell has a containerView which contains all the content of the cell. The containerView and contentView both have clear background colors. All is fine and dandy.

The Problem

Migrating my app to iOS 11, this animation no longer works properly. Below is a slow-animation of it no longer working anymore.

UITableViewCell mask working on iOS 11

As you can see, the custom view is overlayed on top of the previous cell upon cell dismissal with no changes to my code.

Investigation thus far

So far I've determined that the anatomy of a UITableView has changed from this:

UITableView
    UITableViewWrapperView
        cell
            custom view
            contentView
            cell separator view
        cell
        cell
        cell
    UIView
    UIImageView  (scroll view indicator bottom)
    UIImageView  (scroll view indicator right)

To this: (UITableViewWrapperView has been removed)

UITableView
    cell
        custom view
        contentView
        cell separator view
    cell
    cell
    cell
    UIView
    UIImageView (scroll view indicator bottom)
    UIImageView (scroll view indicator right)

One thing I noticed about this UITableWrapperView is that its layer's isOpaque property is true and masksToBounds property is false while the UITableView and all of the UITableViewCells are opposite. Since this view was removed in iOS 11 this might contribute to why I'm having the erroneous effect. I really don't know.

Edit: Another thing I found was that the UITableWrapperView in the working example inserts a mystery UIView at the zero index of it's subviews (all the UITableViewCells) which properties isOpaque is set to true and it has a compositingFilter. This view is subsequently removed after the animation is complete. Since UITableWrapperView is removed in iOS 11, this view is, by association, also missing.

Question

First of all, does anybody know why this this change in behavior is occurring? If not, is there an alternative way to achieve the effect that was working in iOS 10 in a better way? I want clear UITableViewCells but have a custom view that displays behind each cell upon dismissal that is masked by the other clear UITableViewCells when dismissed as shown in the first gif example above.

like image 689
Aaron Avatar asked Oct 27 '17 23:10

Aaron


2 Answers

Before you delete the row, send the cell you're deleting to the back of the table view's subviews.

tableView.sendSubview(toBack: cell)
tableView.deleteRows(at: [indexPath], with: .top)

If you don't have the cell, you can retrieve it using the index path.

guard let cell = tableView.cell(for: indexPath) else { return }

// ...

This should work with an adjustment to the view hierarchy.

View hierarchy adjustment

CardTableViewController.swift

class CardTableViewController: UITableViewController, CardTableViewCellDelegate {

    // MARK: Outlets

    @IBOutlet var segmentedControl: UISegmentedControl!

    // MARK: Properties

    private var cards: [Card] = [
        Card(text: "Etiam porta sem malesuada magna mollis euismod. Maecenas faucibus mollis interdum."),
        Card(text: "Nulla vitae elit libero, a pharetra augue. Cras justo odio, dapibus ac facilisis in, egestas eget quam."),
        Card(text: "Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis.")
    ]

    var selectedIndex: Int { return segmentedControl.selectedSegmentIndex }
    var tableCards: [Card] {
        return cards.filter { selectedIndex == 0 ? !$0.isDone : $0.isDone }
    }

    // MARK: UITableViewDataSource

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

        return tableCards.count
    }

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

        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! CardTableViewCell

        cell.card = tableCards[indexPath.row]

        return cell
    }

    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {

        return UITableViewAutomaticDimension
    }

    // MARK: CardTableViewCellDelegate

    func cardTableViewCell(_ cell: CardTableViewCell, didTouchUpInsideCheckmarkButton button: UIButton) {

        guard let indexPath = tableView.indexPath(for: cell) else { return }

        cell.card?.isDone = button.isSelected
        cell.card?.dayCount += button.isSelected ? 1 : -1
        cell.customView?.dayCount = cell.card?.dayCount

        UIView.animate(withDuration: 0.7, delay: 0.3, options: .curveEaseIn, animations: {
            cell.checkmarkButton?.superview?.transform = .init(translationX: cell.frame.width, y: 0)
            cell.customView?.alpha = 1
        }) { _ in
            self.tableView.sendSubview(toBack: cell)
            self.tableView.deleteRows(at: [indexPath], with: .top)
        }
    }

    // MARK: Actions

    @IBAction func didChangeSegmentedControl(_ sender: UISegmentedControl) {

        tableView.reloadData()
    }
}

CardTableViewCell.swift

@objc protocol CardTableViewCellDelegate {
    func cardTableViewCell(_ cell: CardTableViewCell, didTouchUpInsideCheckmarkButton button: UIButton)
}

@IBDesignable class CardTableViewCell: UITableViewCell {

    // MARK: Outlets

    @IBOutlet var checkmarkButton: UIButton?
    @IBOutlet var customView: CardCustomView?
    @IBOutlet var delegate: CardTableViewCellDelegate?
    @IBOutlet var taskTextLabel: UILabel?

    // MARK: Properties

    var card: Card? {
        didSet {
            updateOutlets()
        }
    }

    // MARK: Lifecycle

    override func awakeFromNib() {

        super.awakeFromNib()

        checkmarkButton?.layer.borderColor = UIColor.black.cgColor
    }

    override func prepareForReuse() {

        super.prepareForReuse()

        card = nil
    }

    // MARK: Methods

    private func updateOutlets() {

        checkmarkButton?.isSelected = card?.isDone == true ? true : false
        checkmarkButton?.superview?.transform = .identity

        customView?.alpha = 0
        customView?.dayCount = card?.dayCount

        taskTextLabel?.text = card?.text
    }

    // MARK: Actions

    @IBAction func didTouchUpInsideCheckButton(_ sender: UIButton) {

        sender.isSelected = !sender.isSelected

        delegate?.cardTableViewCell(self, didTouchUpInsideCheckmarkButton: sender)
    }
}

class CardCustomView: UIView {

    // MARK: Outlets

    @IBOutlet var countLabel: UILabel?
    @IBOutlet var daysLabel: UILabel?

    // MARK: Properties

    var dayCount: Int? {
        didSet { 
            updateOutlets()
        }
    }

    override func awakeFromNib() {

        super.awakeFromNib()

        updateOutlets()
    }

    // MARK: Methods

    private func updateOutlets() {

        let count = dayCount.flatMap { $0 } ?? 0

        countLabel?.text = "\(dayCount.flatMap { $0 } ?? 0)"
        daysLabel?.text = "Day\(count == 1 ? "" : "s") in a row"
    }
}

Card.swift

class Card: NSObject {

    // MARK: Properties

    var dayCount: Int = 0
    var isDone: Bool = false
    var text: String

    // MARK: Lifecycle

    init(text: String) {
        self.text = text
    }
}
like image 83
Callam Avatar answered Jan 04 '23 05:01

Callam


First of all I don't think a missing UITableViewWrapperView is a problem. This is parent view and parents can't obscure child views.

Note that problem is which cell obscures which. In first case row A obscures row B and in second row B obscures row A (in this case your custom B cell has transparent background).

So looks like Apple has screw up relations between cells. To fix it I would try to fix this relations. So before second part of animation starts I would try:

bring cell to back

cell.superview.sendSubview(toBack: cell)

This shouldn't break anything (on older iOS versions) and should resolve issue wiht iOS 11.

like image 21
Marek R Avatar answered Jan 04 '23 07:01

Marek R