Nested UITableViews & Apple's built-in ViewControllers

Nested UITableViews & Apple's built-in ViewControllers

Documenting GSoC '23: Chapter 1

Initial approach

The menu is essentially a set of tables with cells that take you to the respective sections. On a particular screen of the menu app, there are sub-sections we must look into. For example, on the About screen, Scribe has sections such as Community, Feedback and Support and Legal. My initial idea was to have a label along with an UITableView for each section. This presented the following problems:

  • Too many IBOutlet variables.

  • ScrollView constraint complexities.

  • Not a friendly addition process in case new sections or menu options were to be added in the future.

My struggle with ScrollView auto layout

Turns out, I am not good at figuring out how to assign constraints to a scroll view without Xcode screaming at me. To break down the constraints we need to first understand the difference between alignment constraints and content constraints.

Consider an image view. We can assume that the content of the image view has a fixed size. There won't be a case where the image is smaller or, worse, larger than its container. In both scenarios, the image will be distorted in some way. This is where we specify 'scale to fit' or 'scale to fill.' The frame of the image view represents the content that we see.

For a ScrollView, that may not be the case. This is the primary reason we use a scroll view in the first place: our content is larger than the screen. Therefore, we need at least one dynamic constraint on the content (usually in the scrolling direction). For example, in a vertical scroll, we need the height to be dynamic.

What we assign to the ScrollView is a constraint on the ScrollView itself, not on the content. I understand that this can be confusing, but please bear with me. The constraints applied to the ScrollView determine the scrollable area of the screen. We then add a container view to hold the ScrollView's frame together. Inside this container, we place our actual content and add constraints with respect to the container, not the scroll view.

With the individual UITableView approach, this implies having individual constraints for each of these table views as well.

Here are a few articles that can help understand this further. If there's anything you can take away from my rant, content should not be constrained with respect to the ScrollView but with the container it sits in.

Article link: https://tingyishih.medium.com/ios-scrollview-constraints-8d8140d329a0

  1. Content View refers to “the actual full content”, while Scroll View refers to “the outer frame”, i.e., the visible part of the content.

  2. Alignment constraints between Scroll View and Content View define “the scrollable range of the content”, which have nothing to do with the actual size of Content View.

  3. Don’t simultaneously assign Content View the constraints to its size and its alignment.

Another article from FreeCodeCamp that helped is hyperlinked here.

Nested table approach

If you look at the design files for the menu closely, we can see a repeating structure. It is a heading followed by its section content as table cells. We can identify a nested table structure where the parent table has a clear background. The data can be modeled as an array of sections, where each section has an array of row elements corresponding to it inside.

// Parent Model
struct ParentTableCellModel {
  let headingTitle: String
  let section: [Section]
}

// Model for each top-level section
struct Section {
  let sectionTitle: String
  let imageString: String
  let hasToggle: Bool
  let sectionState: SectionState
}

We can then prototype the parent cell to have a label inside it that takes the section heading and add a child table inside it. Here's a screenshot of the .xib file:

Each table row cell can then be prototyped to have its icon and label along with a chevron or toggle based on if the section allows for a toggle.

This in a way allows us to run a sort of loop over data that automatically creates the required labels and child tables. We can thus avoid having to create separate IBOutlets for each label and child table. Also, adding new sections becomes easier.

Here's a step-wise breakdown to implement this approach:

  1. Add a UITableView to your main app screen. Add constraints so it occupies the entire screen.

  2. Consider this the parent table view. The cells of this table view will deque to return a cell with the structure of a parent cell seen above. As separate .xib files are involved you will have to register the nib for the table view first.

  3. Implement UITableViewDataSource delegate methods for the parent table in the main view controller file. The row count is returned using the number of ParentTableCellModel instances in the data.

  4. The dequeued cell should configure the inner table view. Assign the cell data while dequeuing.

  5. The ParentTableViewCell file hosts the IBOutlet variables to the parent cell label and child table.

  6. This class itself becomes the data source and delegate for the inner table.

  7. Implement UITableViewDataSource delegate methods for the child table in the ParentTableViewCell file. The row count is returned using the number of elements in the sections array from the aforementioned ParentTableCellModel.

  8. Each child cell when dequeued is configured to show its corresponding data.

  9. You can add additional views inside the parent cell structure to stylize according to your needs. Refrain from assigning fixed heights to views for vertical scrolling table views as the cells should be able to resize.

Thus for the following data:

static var aboutTableData = [
    ParentTableCellModel(
      headingTitle: "Community",
      section: [
        Section(sectionTitle: "See the code on GitHub", imageString: "github", hasToggle: false, sectionState: .github),
        Section(sectionTitle: "Chat with the team on Matrix", imageString: "matrix", hasToggle: false,  sectionState: .matrix),
        Section(sectionTitle: "Wikimedia and Scribe", imageString: "wikimedia", hasToggle: false, sectionState: .wikimedia),
        Section(sectionTitle: "Share Scribe", imageString: "square.and.arrow.up", hasToggle: false, sectionState: .shareScribe)
      ]
    ),
    ParentTableCellModel(
      headingTitle: "Feedback and support",
      section: [
        Section(sectionTitle: "Rate Scribe", imageString: "star", hasToggle: false, sectionState: .rateScribe),
        Section(sectionTitle: "Report a bug", imageString: "ant", hasToggle: false, sectionState: .bugReport),
        Section(sectionTitle: "Send us an email", imageString: "envelope", hasToggle: false, sectionState: .email),
        ]
    ),
    ParentTableCellModel(
      headingTitle: "Legal",
      section: [
        Section(sectionTitle: "Privacy policy", imageString: "lock.shield", hasToggle: false, sectionState: .privacyPolicy),
        Section(sectionTitle: "Third-party licenses", imageString: "thirdPartyLicenses", hasToggle: false, sectionState: .licenses)
      ]
    ),
  ]

We get the following view:

The problem faced with dynamic cell resizing

One of the problems I faced was that I needed the parent cell to estimate the size of its content and resize its cell to fit the child tables. This was important as not all sections had the same amount of rows. Here's a link to the resource that helped me resolve this problem. You can confirm that you are facing this issue by adding an arbitrarily large row height to parent cells to see if the child table is being clipped.

Handling cell row selection

The handling of cell row selection is done through the UITableViewDelegate protocol. This article on 'Handling row selection in a table view' is a good resource available in the Apple docs. The question that arises is: Who should be assigned as the delegate in a nested case? As mentioned earlier, the ParentTableViewCell class acts as the delegate. By conforming the class to the protocol, we can access delegate methods that allow us to perform various interactions, such as selecting and deselecting a row.

Link to UITableViewDelegate Apple doc.

Apple provided ViewControllers for CFA items

Certain actions in the menu are call-for-action items. For example, Rating the app, Share and the Email interface. These often present a new view to the user based on the action. Newer versions of iOS provide us with system components to integrate such elements.

The problem we can face here is that we are handling cell selection inside a class that inherits UITableViewCell instead of UIViewController. So we cannot rely on the present(_:animated:completion:) to present these views. Thus, we need a method to access the parent view controller somehow. This can be achieved by using the following extension on UIView as even a UITableViewCell has a UIView.

extension UIView {
  var parentViewController: UIViewController? {
    var parentResponder: UIResponder? = self
    while parentResponder != nil {
      parentResponder = parentResponder!.next
      if parentResponder is UIViewController {
        return parentResponder as? UIViewController
      }
    }
    return nil
  }
}

We can then access the parent view controller using this computed property for a view.

Rating the app using SKStoreReviewController

SKStoreReviewController is part of StoreKit. This article by SwiftLee helped me in this area. It allows us to present a view that allows the user to review the app by presenting a pop-up.

The requestReview(in:) takes a UIWindowScene as a parameter. For macOS, we can directly call the function without passing a window. We do need to get a target scene for presenting in iOS. So we fetch the current foreground scene. As you can see in the article, this can be achieved using the following extension on UIApplication:

extension UIApplication {
  var foregroundActiveScene: UIWindowScene? {
    connectedScenes
      .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
  }
}

For iOS, the request review method is supported by versions 14.0 and above. As Scribe supports a deployment target of iOS 13, we need to implement a fallback for rating this app. We can do so by presenting an alert that redirects the user to the app store page for the app. Following is the code for the alert:

let alert = UIAlertController(title: "Enjoying Scribe?", message: "Rate Scribe on the App Store.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Continue", style: .default, handler: openScribeAppStore(alert:)))
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
parentViewController?.present(alert, animated: true)

We can add a if #available check to see what can be presented based on the user's OS version.

if #available(iOS 14.0, *) {
    // present SKStoreReviewController
} else {
    // present alert
}

Adding a share sheet

We can present a share sheet using UIActivityViewController.

We will have to make use of the parentViewController variable to present this as well. The view controller takes two parameters -- activityItems and applicationActivities.

We are trying to share the URL to Scribe on the App Store. Thus, this URL will be one of our activityItems. We can have multiple activityItems as they are passed in an array. For example, an app can be capable of sharing a photo as well as an URL to the photo. applicationActivities can be nil.

Note: Links to an app or links in general, can be encoded. Look for spaces or special characters like #. These imply that a link is encoded. If a link is encoded and we try to create a URL instance from that string, we won't get a valid URL object. We need to add encoding to the string using the addingPercentEncoding method. Link to Docs. If you are passing a valid URL string and getting nil, try creating an URL with the encoded string.

Link to UIActivityViewController docs.

Adding an Email interface

We can use the MFMailComposeViewController from Apple to compose an email. This view controller first checks if the device is configured to send email. Thus, if you run this on the simulator, the .canSendMail method will return false as the simulator doesn't have the mail app.

Apple's directive: You should not attempt to use this interface if the canSendMail() method returns false.

Thus to handle the false case, I am using an alert again that shows the user the email they can reach out to.

let alert = UIAlertController(title: "Send us an email?", message: "Reach out to us at scribe.language@gmail.com", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
parentViewController?.present(alert, animated: true)

If the check function returns true, we can then set the email recipients, the subject of the email as well as the email body. This can be useful to provide the user with an email template. It also takes away the friction for the user in case they want to reach out.

The Scribe menu has distinct sections that cater to various reasons why a user might want to reach out, including bug reports and feedback. As a result, the user's reason for writing an email becomes ambiguous. As a solution, I opted not to provide a body for the email and used a general subject line that simply says "Hey Scribe!".

let mailComposeViewController = MFMailComposeViewController()
mailComposeViewController.mailComposeDelegate = self
mailComposeViewController.setToRecipients(["scribe.language@gmail.com"])
mailComposeViewController.setSubject("Hey Scribe!")

parentViewController?.present(mailComposeViewController, animated: true, completion: nil)

Link to MFMailComposeViewController docs.

To summarise,

The past two weeks I learned the intricacies of ScrollView; really learned to battle with auto layout constraints to make stuff work. Learned about built-in view controllers provided by Apple in some of their frameworks. Had regular check-ins with the team. Was able to implement the skeleton of the app menu with continuous improvements. Also, had my first couple of PRs merged into the codebase for my GSoC project! 🚀

Thank you so much for tagging along my journey! Sharing my socials below for anyone interested :)

Twitter | LinkedIn

Until the next one...