Setup
- Open browser to cell page
- Review where we are in the project (show git log)
- Demo where we are (run current app) and show where we are going (finished cell)
- Explain what table view cells are
- How they work
- Why iOS is able to scroll so quickly and be so efficient
- Explain that we could do this with a nib, but lets do this one entirely programmatically so you can see how that would work.
Setup:
- Create a folder in
AccountSummary
calledHeader
- Put header files in there
- Create a another folder in
AccountSummary
calledCells
- Create a new class in
Cells
calledAccountSummaryCell
.
AccountSummaryCell
import Foundation
import UIKit
class AccountSummaryCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setup()
layout()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension AccountSummaryCell {
private func setup() {
}
private func layout() {
}
}
How we are going to lay this out.
Let's add all the elements then style them one-by-one. Starting with the account typeLabel
.
let typeLabel = UILabel()
static let reuseID = "AccountSummaryCell"
static let rowHeight: CGFloat = 100
typeLabel.translatesAutoresizingMaskIntoConstraints = false
typeLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
typeLabel.adjustsFontForContentSizeCategory = true
typeLabel.text = "Account type"
contentView.addSubview(typeLabel) // imporant! Add to contentView.
NSLayoutConstraint.activate([
typeLabel.topAnchor.constraint(equalToSystemSpacingBelow: topAnchor, multiplier: 2),
typeLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 2),
])
Now let's go register and dequeue this cell in our view controller.
AccountSummaryViewController
private func setupTableView() {
tableView.delegate = self
tableView.dataSource = self
tableView.register(AccountSummaryCell.self, forCellReuseIdentifier: AccountSummaryCell.reuseID)
tableView.rowHeight = AccountSummaryCell.rowHeight
tableView.tableFooterView = UIView()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSummaryCell.reuseID, for: indexPath) as! AccountSummaryCell
return cell
}
Run the app.
Discussion on auto layout APIs:
We could have done our auto layout in the cell explicitly like this:
typeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16),
typeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- Demo constraits using fluid constraint language.
- How it reads.
- Advantage of standard spacing.
- Preferrred approach.
- Demo constraints using constant values (i.e.
8
and16
)- Why spaces are not multiples of 8pts.
- Good for non-standard sizing.
Discussion on Cell height:
- if your cell height heights are all the same recommend way to set cell height is once for all cells like this:
tableView.rowHeight = AccountSummaryCell.rowHeight
- if your cell heights vary by cell you can introduce logic and set them specifically like this
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return AccountSummaryCell.rowHeight
}
- How would you do this?
- Spend 10 seconds thinking about it then come back and we'll implement together.
- Discussion: Everything is a view
- When you see blocks and rectangles like this, just think view. Most dividers and underlines are done this way.
**AccountSummaryCell **
let underlineView = UIView()
underlineView.translatesAutoresizingMaskIntoConstraints = false
underlineView.backgroundColor = appColor
contentView.addSubview(underlineView)
underlineView.topAnchor.constraint(equalToSystemSpacingBelow: typeLabel.bottomAnchor, multiplier: 1),
underlineView.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 2),
underlineView.widthAnchor.constraint(equalToConstant: 60),
underlineView.heightAnchor.constraint(equalToConstant: 4),
See if you can lay this one out:
- font:
.body
- text:
Account name
- topSpace:
16pts
(multiplier or x2) - leading:
16pts
(multiplier or x2) - trailing: ignore for now (this is enough to define)
let nameLabel = UILabel()
nameLabel.translatesAutoresizingMaskIntoConstraints = false
nameLabel.font = UIFont.preferredFont(forTextStyle: .body)
nameLabel.text = "Account name"
contentView.addSubview(nameLabel)
nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: underlineView.bottomAnchor, multiplier: 2),
nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 2),
Going to do something a bit different here. Instead of laying out each individually, going to embed into a stack view.
let balanceStackView = UIStackView()
let balanceLabel = UILabel()
let balanceAmountLabel = UILabel()
balanceStackView.translatesAutoresizingMaskIntoConstraints = false
balanceStackView.axis = .vertical
balanceStackView.spacing = 0
balanceLabel.translatesAutoresizingMaskIntoConstraints = false
balanceLabel.font = UIFont.preferredFont(forTextStyle: .body)
balanceLabel.textAlignment = .right
balanceLabel.text = "Some balance"
balanceAmountLabel.translatesAutoresizingMaskIntoConstraints = false
balanceAmountLabel.textAlignment = .right
balanceAmountLabel.text = "$929,466.63"
balanceStackView.addArrangedSubview(balanceLabel)
balanceStackView.addArrangedSubview(balanceAmountLabel)
contentView.addSubview(balanceStackView)
balanceStackView.topAnchor.constraint(equalToSystemSpacingBelow: underlineView.bottomAnchor, multiplier: 0),
balanceStackView.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4),
trailingAnchor.constraint(equalToSystemSpacingAfter: balanceStackView.trailingAnchor, multiplier: 4),
let chevronImageView = UIImageView()
chevronImageView.translatesAutoresizingMaskIntoConstraints = false
let chevronImage = UIImage(systemName: "chevron.right")!.withTintColor(appColor, renderingMode: .alwaysOriginal)
chevronImageView.image = chevronImage
contentView.addSubview(chevronImageView)
chevronImageView.topAnchor.constraint(equalToSystemSpacingBelow: underlineView.bottomAnchor, multiplier: 1),
trailingAnchor.constraint(equalToSystemSpacingAfter: chevronImageView.trailingAnchor, multiplier: 1)
OK at this point we have successfully styled our cell. Let's save our work. Note how all this work is still on our branch.
> git add .
> git commit -m "feat: Style cell view"
- Demo / explain what these things are and how they work.
- Let's use these to make our balance fancy 🚀
extension AccountSummaryCell {
private func makeFormattedBalance(dollars: String, cents: String) -> NSMutableAttributedString {
let dollarSignAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .callout), .baselineOffset: 8]
let dollarAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .title1)]
let centAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .footnote), .baselineOffset: 8]
let rootString = NSMutableAttributedString(string: "$", attributes: dollarSignAttributes)
let dollarString = NSAttributedString(string: dollars, attributes: dollarAttributes)
let centString = NSAttributedString(string: cents, attributes: centAttributes)
rootString.append(dollarString)
rootString.append(centString)
return rootString
}
}
balanceAmountLabel.attributedText = makeFormattedBalance(dollars: "929,466", cents: "23")
static let rowHeight: CGFloat = 112
Let's save our work. But this time incrementally.
> git add -p
> git commit -m "feat: Format bank balance"
To get rid of changes, or start over at last commit:
> git reset --hard
Now this is nice, but what would be really nice is if we could re-use this account tile view for different types of accounts.
Turns out we can. We just need to give it a way to determine what account type to display, and then give it the neccessary details.
Enums are really good for this. Let's start by defining an account type, and then seeing what data we need to vary by type.
Discussion
- enums
- ViewModel
- passing in data via the viewModel
Let's start by simplying introducing an enum for AccountType
and a ViewModel with accountType
that can be figured after the cell has loaded.
AccountSummaryCell
enum AccountType: String {
case Banking
case CreditCard
case Investment
}
Sticking this in AccountSummaryCell
to signal this type is only for this cell. Could put externally but then you start to run into name clashes. This avoids that.
Next let's define a view model for this cell and give it an accountType
and accountName
.
struct ViewModel {
let accountType: AccountType
let accountName: String
}
let viewModel: ViewModel? = nil
ViewModel is optional because we won't know what data this cell has when its initially dequeued in the table. But we'll set after imitating the flow as if it got set as the result of a network call.
First we'll add the configuration method:
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
}
}
Then look and see how we can vary configuration based on type:
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
switch vm.accountType {
case .Banking:
break
case .CreditCard:
break
case .Investment:
break
}
}
}
Now we have a place to vary our cell configuration. All we need to do is set it when the cell gets created.
First let's get rid of our old game data model (delete games
). And instead replace it and empty data set containing exactly the data we want.
AccountSummaryViewController
class AccountSummaryViewController: UIViewController {
var accounts: [AccountSummaryCell.ViewModel] = []
var tableView = UITableView()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return accounts.count
}
Then let's pretend to fetch the data over the network like this:
extension AccountSummaryViewController {
private func setup() {
setupTableView()
setupTableHeaderView()
fetchData()
}
}
extension AccountSummaryViewController {
private func fetchData() {
let savings = AccountSummaryCell.ViewModel(accountType: .Banking,
accountName: "Basic Savings")
let visa = AccountSummaryCell.ViewModel(accountType: .CreditCard,
accountName: "Visa Avion Card")
let investment = AccountSummaryCell.ViewModel(accountType: .Investment,
accountName: "Tax-Free Saver")
accounts.append(savings)
accounts.append(visa)
accounts.append(investment)
}
}
And then pass it to our cell like this.
extension AccountSummaryViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard !accounts.isEmpty else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSummaryCell.reuseID, for: indexPath) as! AccountSummaryCell
let account = accounts[indexPath.row]
cell.configure(with: account)
return cell
}
}
Now if we run the app, we won't notice anything different. Everything looks the same.
But watch what happens when we leverage the account type. Because our account type enum is a string, we can use if for setting the typeLabel
.
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
typeLabel.text = vm.accountType.rawValue
switch vm.accountType {
case .Banking:
break
case .CreditCard:
break
case .Investment:
break
}
}
}
But wait. It gets better. Because we can now vary by type, we can further configure our cell based on very specifiy things. Like setting the underlineView
color.
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
typeLabel.text = vm.accountType.rawValue
switch vm.accountType {
case .Banking:
underlineView.backgroundColor = appColor
case .CreditCard:
underlineView.backgroundColor = .systemOrange
case .Investment:
underlineView.backgroundColor = .systemPurple
}
}
}
And now the world is our oyster. We can set and configure anything we want in this cell simply by:
- updating the
ViewModel
- leverage the
accountType
via the switch
See if you can update the nameLabel
by setting it equal to the accountName
from our viewModel.
nameLabel.text = vm.accountName
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
typeLabel.text = vm.accountType.rawValue
nameLabel.text = vm.accountName
}
}
extension AccountSummaryCell {
func configure(with vm: ViewModel) {
typeLabel.text = vm.accountType.rawValue
nameLabel.text = vm.accountName
switch vm.accountType {
case .Banking:
underlineView.backgroundColor = .systemTeal
balanceLabel.text = "Current balance"
case .CreditCard:
underlineView.backgroundColor = .systemOrange
balanceLabel.text = "Current balance"
case .Investment:
underlineView.backgroundColor = .systemPurple
balanceLabel.text = "Value"
}
}
}
If we run that now we'll see the balance label properly set.
> git add .
> git commit -m "feat: Add view model"
Next comes the tricky part. We need to update our view model so that when we pass in a balance.
struct ViewModel {
let accountType: AccountType
let accountName: String
let balance: Decimal // new
}
let savings = AccountSummaryCell.ViewModel(accountType: .Banking,
accountName: "Basic Savings",
balance: 929466.23)
let chequing = AccountSummaryCell.ViewModel(accountType: .Banking,
accountName: "No-Fee All-In Chequing",
balance: 17562.44)
let visa = AccountSummaryCell.ViewModel(accountType: .CreditCard,
accountName: "Visa Avion Card",
balance: 412.83)
let masterCard = AccountSummaryCell.ViewModel(accountType: .CreditCard,
accountName: "Student Mastercard",
balance: 50.83)
let investment1 = AccountSummaryCell.ViewModel(accountType: .Investment,
accountName: "Tax-Free Saver",
balance: 2000.00)
let investment2 = AccountSummaryCell.ViewModel(accountType: .Investment,
accountName: "Growth Fund",
balance: 15000.00)
accounts.append(savings)
accounts.append(chequing)
accounts.append(visa)
accounts.append(masterCard)
accounts.append(investment1)
accounts.append(investment2)
We some how convert that Decimal
into a two part String.
Steps:
- first we have to convert our
Decial
into aDouble
because that is what number formatter uses - then we need to convert that
Double
into dollars and cents formatted - then we need to separate out the dollars and cents
- then can pass to attributed string func
Create a file Decimal+Utils
in Util and into there copy.
extension Decimal {
var doubleValue: Double {
return NSDecimalNumber(decimal:self).doubleValue
}
}
Create a file CurrencyFormatter
in Util and copy the following.
import UIKit
struct CurrencyFormatter {
func makeAttributedCurrency(_ amount: Decimal) -> NSMutableAttributedString {
let tuple = breakIntoDollarsAndCents(amount)
return makeBalanceAttributed(dollars: tuple.0, cents: tuple.1)
}
// Converts 929466.23 > "929,466" "23"
func breakIntoDollarsAndCents(_ amount: Decimal) -> (String, String) {
let tuple = modf(amount.doubleValue)
let dollars = convertDollar(tuple.0)
let cents = convertCents(tuple.1)
return (dollars, cents)
}
// Converts 929466 > 929,466
private func convertDollar(_ dollarPart: Double) -> String {
let dollarsWithDecimal = dollarsFormatted(dollarPart) // "$929,466.00"
let formatter = NumberFormatter()
let decimalSeparator = formatter.decimalSeparator! // "."
let dollarComponents = dollarsWithDecimal.components(separatedBy: decimalSeparator) // "$929,466" "00"
var dollars = dollarComponents.first! // "$929,466"
dollars.removeFirst() // "929,466"
return dollars
}
// Convert 0.23 > 23
private func convertCents(_ centPart: Double) -> String {
let cents: String
if centPart == 0 {
cents = "00"
} else {
cents = String(format: "%.0f", centPart * 100)
}
return cents
}
// Converts 929466 > $929,466.00
func dollarsFormatted(_ dollars: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.usesGroupingSeparator = true
if let result = formatter.string(from: dollars as NSNumber) {
return result
}
return ""
}
private func makeBalanceAttributed(dollars: String, cents: String) -> NSMutableAttributedString {
let dollarSignAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .callout), .baselineOffset: 8]
let dollarAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .title1)]
let centAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .callout), .baselineOffset: 8]
let rootString = NSMutableAttributedString(string: "$", attributes: dollarSignAttributes)
let dollarString = NSAttributedString(string: dollars, attributes: dollarAttributes)
let centString = NSAttributedString(string: cents, attributes: centAttributes)
rootString.append(dollarString)
rootString.append(centString)
return rootString
}
}
The use in ViewModel
like this.
struct ViewModel {
let accountType: AccountType
let accountName: String
let balance: Decimal
var balanceAsAttributedString: NSAttributedString {
return CurrencyFormatter().makeAttributedCurrency(balance)
}
}
Discussion
- why did we choose
Decimal
as our money type - Walk the solution explaining
modf
andtuple
- Set break points and walk code
> git add -p
> git commit -m "feat: Add CurrencyFormatter"
- Open
AppDelegate
. - Remove reference to `DummyViewController.
- Make
MainViewController
root vc - Add
loginViewController.delegate = self
onboardingViewController.delegate = self
let vc = mainViewController
vc.setStatusBar()
UINavigationBar.appearance().isTranslucent = false
UINavigationBar.appearance().backgroundColor = appColor
window?.rootViewController = vc
return true
The update our delegates in AppDelegate
to use mainViewController
.
extension AppDelegate: LoginViewControllerDelegate {
func didLogin() {
if LocalState.hasOnboarded {
setRootViewController(mainViewController) // here
} else {
setRootViewController(onboardingViewController)
}
}
}
extension AppDelegate: OnboardingContainerViewControllerDelegate {
func didFinishOnboarding() {
LocalState.hasOnboarded = true
setRootViewController(mainViewController) // here
}
}
Then set the background color of the AccountSummaryVC
like this.
AccountSummaryViewController
private func setupTableView() {
tableView.backgroundColor = appColor
``
### Save work
git add . git commit -m "ux: Fix status and nav bar background colors"
### Links that help
- [What are nibs?](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/NibFile.html)
- [Container Views](https://developer.apple.com/library/archive/featuredarticles/ViewControllerPGforiPhoneOS/ImplementingaContainerViewController.html)
- [Table Headers & Footer](https://developer.apple.com/documentation/uikit/views_and_controls/table_views/adding_headers_and_footers_to_table_sections)
- [NSAttributedStrings](https://github.com/jrasmusson/swift-arcade/blob/master/Foundation/NSAttributedStrings/README.md)
- [modf](https://www.tutorialspoint.com/c_standard_library/c_function_modf.htm)