1

I’m building something like a todo app where I have EXPANDABLE “slave” UITableView inside “master” UITableViewCell (reason is “material design of expandable “slave” table). Maybe is also relevant that this is all inside a container UIView inside UIScrollView embed in NavigationViewController and TabViewController. Pretty complex... Let me explain:

  • “Master” UITableViewControler with 2 section (this year/long term) with custom headers and custom TableViewCell

  • Custom TableViewCell has a UIView and “slave” UITableView inside - underlying UIView is constrained to “slave” UITableView and makes it’s “material” design with shadow (cropToBounds prevents shadow on UITableView)

  • “Slave” UITableView should be with only one expanding section (I followed logic of this guy: https://www.youtube.com/watch?v=ClrSpJ3txAs) – tap on first row hides/show subviews and footer

  • “Slave” UITableView has 3 custom TableViewCell (“header” populated always at first row, “subtask” starting on second and populated based on number of subtasks, “footer” always the last)

Picture of so far ugly UI might make it more clear:

Interface Builder setup

UI design

I am trying to use Interface Builder as much as possible (for me as a beginner it saves a lot of code and makes things more clear).

Code wise it is a bit complex architecture since I have a “Goal” realm object that has every time a list of “Subtask” objects. So the “master” UITableViewController dataSource grabs a goal and pass it to “master’s” TableViewCell (GoalMainCell) cell.goal = goals?[indexPath.row] that is dataSource and Delegate for its outlet “slave” UITableView. This way I can populate “slave” UITableView with its correct subtasks from realm.

When I tried to have “master” UITableViewController a dataSource & delegate of both tables I wasn’t able to populate subtasks properly (even setting tableView.tag for each and bunch of if…else statements – indexPath.row can’t be taken as a goal's index since it starts from 0 for each “slave” tableView.row)

class GoalsTableViewController: UITableViewController: (master tableviewcontroller)

let realm = try! Realm()
var goals: Results<Goal>?
var parentVc : OurTasksViewController?
var numberOfSubtasks: Int?

let longTermGoalsCount = 0

@IBOutlet var mainTableView: UITableView!
@IBOutlet weak var noGoalsLabel: UILabel!

override func viewDidLoad() {
    super.viewDidLoad()
    loadGoals()
}

override func viewDidAppear(_ animated: Bool) { // MUST be viewDidAppear
    super.viewDidAppear(true)
    parentVc = self.parent as? OurTasksViewController
}

func loadGoals() {
    goals = realm.objects(Goal.self)
    if goals?.count == 0 || goals?.count == nil { noGoalsLabel.isHidden = false }
    tableView.reloadData()
}

override func numberOfSections(in tableView: UITableView) -> Int {
    if longTermGoalsCount > 0 {
        //TODO: split goals to "this year" and "future" and show second section if future has some goals
        return 2
    } else {
        return 1
    }
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return goals?.count ?? 0
}


override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "GoalMainCell", for: indexPath) as! GoalsMainCell
    cell.goal = goals?[indexPath.row] //send goal in to the GoalMainCell controller
    cell.layoutIfNeeded()
    return cell

}

override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    if section == 0 {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ThisYearGoalsHeaderTableViewCell") as! ThisYearGoalsHeaderTableViewCell
        cell.layoutIfNeeded()
        return cell
    } else {
        let cell = tableView.dequeueReusableCell(withIdentifier: "LongTermGoalsHeaderCell")!
        cell.layoutIfNeeded()
        return cell
    }
}

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

override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return 44
}

override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return UITableView.automaticDimension
}

override func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat {
    return 44
}

class GoalsMainCell: UITableViewCell (custom cell of a master table)

@IBOutlet weak var goalsSlaveTableView: GoalsSlaveTableView! 

let realm = try! Realm()
var subtasks: Results<GoalSubtask>?
var numberOfRows: Int = 0
var goal: Goal? {   //goal passed from master tableView
    didSet {
        loadSubtasks()
    }
}

func loadSubtasks() {
    subtasks = goal?.subtasks.sorted(byKeyPath: "targetDate", ascending: true)
    guard let expanded = goal?.expanded else {
        numberOfRows = 1
        return
    }
    if expanded {
        numberOfRows = (subtasks?.count ?? 0) + 2
    } else {
        numberOfRows = 1
    }
}

extension GoalsMainCell: UITableViewDataSource (custom cell of a master table)

func numberOfSections(in tableView: UITableView) -> Int { return 1 }

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return numberOfRows
}

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

        if indexPath.row == 0 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "GoalsSlaveTableViewHeaderCell", for: indexPath) as! GoalsSlaveTableViewHeaderCell
            cell.goalNameLabel.text = goal?.name ?? "No task added"
            cell.layoutIfNeeded()
            return cell
        } else if indexPath.row > 0 && indexPath.row < numberOfRows - 1 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "GoalsSlaveTableViewCell", for: indexPath) as! GoalsSlaveTableViewCell
            cell.subTaskNameLabel.text = subtasks?[indexPath.row - 1].name  //because first row is main goal name
            cell.layoutIfNeeded()
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withIdentifier: "GoalsSlaveTableViewFooterCell", for: indexPath) as! GoalsSlaveTableViewFooterCell
            cell.layoutIfNeeded()
            return cell
        }
    }

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return UITableView.automaticDimension
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return 44
}

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return 0
}

func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat {
    return 0
}

extension GoalsMainCell: UITableViewDelegate (custom cell of a master table)

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if indexPath.row == 0 {
        if goal != nil {
            do {
                try realm.write {
                    goal!.expanded = !goal!.expanded
                }
            } catch {
                print("Error saving done status, \(error)")
            }
        }
    }
    let section = IndexSet.init(integer: indexPath.section)
    tableView.reloadSections(section, with: .none)

    tableView.deselectRow(at: indexPath, animated: true)
}

class GoalsSlaveTableView: UITableView (slave tableViewController)

override func layoutSubviews() {
    super.layoutSubviews()
    self.layer.cornerRadius = cornerRadius
}

override var intrinsicContentSize: CGSize {
    self.layoutIfNeeded()
    return self.contentSize
}

override var contentSize: CGSize {
    didSet{
        self.invalidateIntrinsicContentSize()
    }
}

class GoalsSlaveTableViewCell: UITableViewCell (cell of the slave table)

@IBOutlet weak var subTaskNameLabel: UILabel!
@IBOutlet weak var subTaskTargetDate: UILabel!
@IBOutlet weak var subTaskDoneImage: UIImageView!

class GoalsSlaveTableViewHeaderCell: UITableViewCell (header of the slave table - actually also a custom cell)

@IBOutlet weak var goalNameLabel: UILabel!
@IBOutlet weak var goalTargetDate: UILabel!
@IBOutlet weak var goalProgressBar: UIProgressView!

class GoalsSlaveTableViewFooterCell: UITableViewCell (and footer for the slave)

@IBAction func deleteGoalButtonTapped(_ sender: UIButton) {
    print("Delete goal")
}

@IBAction func editGoalButtonTapped(_ sender: UIButton) {
    print("Edit goal")
}

Question: How to call reload data with animation (for both if necessary) table views after expanding/collapsing?

I got it working pretty much as I want from the look. The only and probably the most tricky thing missing is the auto-reload / adjust of the “master” cell and “slave” tableView size upon expanding/collapsing. In other words: When I tap the first row in “slave” table the data in Realm get updated (“expanded” bool property) but I have to terminate the app and launch again to get the layout set up (I believe viewDidLoad has to run). Just a few links I used for inspiration, but I found none that would explain how to expand/colapse and call reload of both in terms of size with a nice animation during a runtime:

Using Auto Layout in UITableView for dynamic cell layouts & variable row heights Is it possible to implement tableview inside tableview cell in swift 3?

Is it possible to add UITableView within a UITableViewCell

TableView inside tableview cell swift 3

TableView Automatic Dimension Tableview Inside Tableview

Reload a tableView inside viewController

As a beginner I might be doing some really simple mistake like missing some auto-layout constrains in IB and therefore calling invalidateIntrinsicContentSize()… Or maybe with this architecture it is not possible to do animated smooth table reloads since there will be always conflict of "reloads"...? Hopefully there is someone out there to help me. Thank you for any help!

Lukas Smilek
  • 41
  • 1
  • 7

2 Answers2

1

I solved the animation/update.

1) I forgot to reload data after changing .expanded property:

extension GoalsMainCell: UITableViewDelegate

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let goalNotificationInfo = ["index" : goalIndex ]
    if indexPath.row == 0 {
        if goal != nil {
            do {
                try realm.write {
                    goal!.expanded = !goal!.expanded
                }
            } catch {
                print("Error saving done status, \(error)")
            }
        }
        loadSubtasks()
        tableView.deselectRow(at: indexPath, animated: true)

        let section = IndexSet.init(integer: indexPath.section)
        tableView.reloadSections(section, with: .none)

        NotificationCenter.default.post(name: .goalNotKey, object: nil, userInfo: goalNotificationInfo as [AnyHashable : Any])
    }



}

2) I added notification observer to the slave table delegate and call .beginUpdate() + .endUpdate()

@objc func updateGoalSection(_ notification: Notification) {

    tableView.beginUpdates()

    tableView.endUpdates()

  }

Now the update works with a smooth transition. Of coarse solving this revealed another issues with indexing and main call automatic dimensions but that is another topic...

Hope it helpes other to struggle less.

Lukas Smilek
  • 41
  • 1
  • 7
0

you might consider using collectionView in this case. Decoration view can serve as a “material design” background. This might also increase loading and scrolling performance as there would be fewer tables - only one collection - to load and you would call reload only in one class.

Of coarse collection, the view takes more time to set up.

Mehul Kabaria
  • 6,404
  • 4
  • 25
  • 50
Lububu
  • 1
  • 1