My TableView consists of four cells (for blacklisting categories) with a boolean property (indicated with a checkmark) and another cell with a UISwitch
embedded in it. My current controller is as follows:
class TableViewController: UITableViewController {
var blacklisted = Set<Category>()
var flags = Set<String>()
@IBAction func toggle(_ sender: UISwitch) {
if (sender.isOn) {
print("if")
} else {
print("else")
}
}
@IBAction func cancel(_ sender: UIBarButtonItem) {
self.dismiss(animated: true)
}
@IBAction func done(_ sender: UIBarButtonItem) {
ViewController.allowed = ViewController.allowed.subtracting(blacklisted)
ViewController.flags = ViewController.flags.union(flags)
print(ViewController.allowed)
print(ViewController.flags)
self.dismiss(animated: true)
}
var states = [PastState.UNSELECTED, PastState.UNSELECTED, PastState.UNSELECTED, PastState.UNSELECTED]
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if (indexPath.section != 0) {
return
}
tableView.cellForRow(at: indexPath)?.accessoryType
= (states[indexPath.row] == PastState.SELECTED)
? .none
: .checkmark
switch (indexPath.row) {
case 0:
TableViewController.toggleItem(if: states[0], set: &flags, with: "nsfw")
break
case 1:
TableViewController.toggleItem(if: states[1], set: &flags, with: "religious", "political")
break
case 2:
TableViewController.toggleItem(if: states[2], set: &blacklisted, with: Category.Programming)
break
case 3:
TableViewController.toggleItem(if: states[3], set: &blacklisted, with: Category.Dark)
break
default: break
}
states[indexPath.row] = (states[indexPath.row] == PastState.SELECTED)
? PastState.UNSELECTED
: PastState.SELECTED
}
static func toggleItem<T: Hashable>(`if` state: PastState, set: inout Set<T>, with items: T...) {
if (state == PastState.UNSELECTED) {
for item in items {
set.insert(item)
}
} else {
for item in items {
set.remove(item)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
I've been told the code is spaghetti code and to separate the model, but I'm not quite too sure how.
1 Answer 1
There are quite a few issues here:
In
switch
statement, you don’t need/wantbreak
statements. Unlike C languages, Swiftswitch
statements don’tfallthrough
by default. To usebreak
where it is not needed is very unswifty.I’d make
toggleItem
an instance method.The properties of the
ViewController
should not bestatic
.This table view controller should not be reaching into
ViewController
to update its properties, which tightly couples these two classes. We should strive for far looser coupling in our code.Admittedly, because
ViewController
is presentingTableViewController
, the presenting view controller might go ahead and update properties in the presented view controller, in order to pass data to down the chain. ButTableViewController
should never be reaching back up the chain. That makes them too tightly coupled and only allowsTableViewController
to be called from that one view controller, whereas as your app grows, you might want to call it from elsewhere.The typical solution to achieve loose coupling is the delegate-protocol pattern. Presented view controllers should not updating properties in the presenting view controller, but rather it should only communicate back via its protocol. This allows us to use this presented view controller from wherever we want.
If you’re going to have an early exit in
didSelectRowAt
(if section ≠ 0), all things being equal, you should preferguard
overif
, as it makes the intent more clear (you seeguard
keyword and you immediately know "oh, this is an early exit for some edge case"). The compiler will also warn us if we accidentally forget to callreturn
.In
if
statements, the use of parentheses around the test clause feels a bit unswifty. Consider:if (state == PastState.UNSELECTED) { ... } else { ... }
Instead, I’d suggest:
if state == PastState.UNSELECTED { ... } else { ... }
In the case of an enumeration, I’d favor
switch
overif
. Thus, instead of the above, I’d suggest:switch state { case PastState.UNSELECTED: ... case PastState.SELECTED: ... }
This ensures that the test is exhaustive. It makes it easier to reason about the code at a glance. Plus, if you, for example, later add a
.NOTDETERMINED
enumeration, the compiler can warn you of all of the non-exhaustiveswitch
statements, but if you useif
-else
, you have to manually pour through all of your code looking for this yourself.Because
PastState
is an enumeration, you can eliminate all of those redundant references to the enumeration name. E.g., using the previous example, you can say:switch state { case .UNSELECTED: ... case .SELECTED: ... }
In your enumerations, you should use camelCase (start with lower case letter, only capitalize start of new word within the case name):
switch state { case .unselected: ... case .selected: ... }
Alternatively, just retire this enumeration altogether and just use a
[Bool]
property. Or, as shown at the end of this answer, if you use a view model, you might refactor this out completely.The
blacklisted
property is using an enumeration, which is good. Butflags
is using strings with values buried in the code. They both should be enumerations.The
done
method is subtractingblacklisted
from theViewController
’sallowed
. But what if you unselected a blacklisted item and needed to add it back to theallowed
? The existing code won’t do that.The same issue applies for
ViewController.flags
, where you’re addingflags
. But what if one was removed?You are setting the
accessoryView
based upon which flags/blacklisted values exist in these sets and toggle this based upon selecting a row. But you then have thisUISwitch
, too. You’d generally use either aUISwitch
or theaccessoryView
, but not both. Pick one or the other. (In my demo, I’m going to retireUISwitch
.)The references to
self.dismiss(...)
can be simplified to justdismiss(...)
.The
didSelectRowAt
is:- toggling the
indicatorView
based upon the oldstate
; - updating the
blacklist
/flags
properties; and - toggling the
state
.
I would suggest that you simply want to toggle the state and then reload that row. Not only does it make this less fragile (you don’t have three different operations that you need to carefully coordinate), but it affords the opportunity to animate the change of the
indicatorView
.- toggling the
I would suggest grouping your view controller methods into extensions. For example, I find it nice to put
UITableViewDataSource
methods in one extension,UITableViewDelegate
methods in another,@IBAction
methods in another, etc. This makes it easy to visually identify/find relevant methods. This also allows you to collapse sections (e.g. ⌘+option+◀︎) sections that you’re not currently working on, making it easy to focus on the work at hand. This, combined with use of theMARK: -
designator makes navigation through your view controller easier.You’ve got an enumeration called
PastState
. But this is not the "past state", but rather the current state as updated as the user proceeds with selecting/unselecting various options. So I might rename thatSelectedState
or something like that.Likewise, I might give
TableViewController
a name that indicates its purpose. E.g. maybeAllowedContentViewController
.I find it confusing that the main view controller uses
allowed
, but theTableViewController
usesblacklisted
(which is presumably an inverted set of the aforementioned). I’d suggest using the same model in both view controllers. IfTableViewController
wants to invert it as part of its determination of what’s checked and what’s not, then it should do that.The most radical change I’m going to suggest is that you should completely remove the business logic from the view controller. The view controller should be handling the interface with the UIKit controls (the table view, the buttons, etc.), but all the logic about which
flags
andCategory
values is represented by which row, how you toggle them, etc., doesn’t belong here. You often put it in what I will call a "view model" (though this entity goes by different names)The virtue of this is two fold. First the view controller suddenly becomes something very concise and easy to understand. Second, when you get around to writing unit tests, you want to be able to test your business logic without worrying about UIKit stuff (which should be part of the UI tests, not the unit tests).
So, pulling this all together, we end up with a view controller like this:
class AllowedContentViewController: UITableViewController {
// properties set by presenting view controller
weak var delegate: AllowedContentViewControllerDelegate?
var allowed: Set<Category>!
var flags: Set<Flag>!
// properties used by this view controller
private var viewModel: AllowedContentViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = AllowedContentViewModel(allowed: allowed, flags: flags)
}
}
// MARK: - Actions
extension AllowedContentViewController {
@IBAction func cancel(_ sender: UIBarButtonItem) {
dismiss(animated: true)
}
@IBAction func done(_ sender: UIBarButtonItem) {
delegate?.allowedContentViewController(self, didUpdateAllowed: viewModel.allowed, flags: viewModel.flags)
dismiss(animated: true)
}
}
// MARK: - UITableViewDataSource
extension AllowedContentViewController {
// you didn't provide the following two, so I'll take a guess what that looked like
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.numberOfRows()
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = viewModel.text(for: indexPath.row)
cell.accessoryType = viewModel.isSelected(row: indexPath.row) ? .checkmark : .none
return cell
}
}
// MARK: - UITableViewDelegate
extension AllowedContentViewController {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard indexPath.section == 0 else {
return
}
viewModel.toggle(row: indexPath.row)
tableView.reloadRows(at: [indexPath], with: .fade)
}
}
That references a protocol to which our presenting view controller will conform:
protocol AllowedContentViewControllerDelegate: class {
func allowedContentViewController(_ viewController: AllowedContentViewController, didUpdateAllowed allowed: Set<Category>, flags: Set<Flag>)
}
The presenting view controller would then do something like so:
class ViewController: UIViewController {
var allowed = Set<Category>()
var flags = Set<Flag>()
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let destination = (segue.destination as? UINavigationController)?.topViewController as? AllowedContentViewController {
destination.flags = flags
destination.allowed = allowed
destination.delegate = self
}
}
}
extension ViewController: AllowedContentViewControllerDelegate {
func allowedContentViewController(_ viewController: AllowedContentViewController, didUpdateAllowed allowed: Set<Category>, flags: Set<Flag>) {
self.allowed = allowed
self.flags = flags
}
}
I obviously made an assumption here of how you presented this AllowedContentViewController
(namely that you must have embedded it in a UINavigationController
, based upon the presence of dismiss
but that you also had @IBAction
s with UIBarButton
). Clearly, adjust the prepare(for:sender:)
according to how you actually presented this table view controller.
Anyway, above, the presenting view controller is responsible for passing data to this presented AllowedContentViewController
and for responding to AllowedContentViewControllerDelegate
delegate method that supplies the updated user preferences for what is allowed and what isn’t. This gets the AllowedContentViewController
from ever having to reach into ViewController
to retrieve/update properties itself. Now AllowedContentViewController
can be used in any context you want.
Finally, our view model has all the business logic that used to be in the TableViewController
:
struct AllowedContentViewModel {
var allowed: Set<Category>
var flags: Set<Flag>
func numberOfRows() -> Int {
return 4
}
func isSelected(row: Int) -> Bool {
switch row {
case 0: return flags.contains(.nsfw)
case 1: return flags.contains(.religious) && flags.contains(.political)
case 2: return !allowed.contains(.programming)
case 3: return !allowed.contains(.dark)
default: fatalError("Unexpected row number")
}
}
private mutating func select(row: Int) {
switch row {
case 0: flags.insert(.nsfw)
case 1: flags.insert(.religious); flags.insert(.political)
case 2: allowed.remove(.programming)
case 3: allowed.remove(.dark)
default: fatalError("Unexpected row number")
}
}
private mutating func unselect(row: Int) {
switch row {
case 0: flags.remove(.nsfw)
case 1: flags.remove(.religious); flags.remove(.political)
case 2: allowed.insert(.programming)
case 3: allowed.insert(.dark)
default: fatalError("Unexpected row number")
}
}
mutating func toggle(row: Int) {
if isSelected(row: row) {
unselect(row: row)
} else {
select(row: row)
}
}
func text(for row: Int) -> String {
switch row {
case 0: return NSLocalizedString("NSFW", comment: "Label")
case 1: return NSLocalizedString("Religious/political", comment: "Label")
case 2: return NSLocalizedString("Blacklisted Programming", comment: "Label")
case 3: return NSLocalizedString("Blacklisted Dark", comment: "Label")
default: fatalError("Unexpected row number")
}
}
}
We can, if we want, then write unit tests for this view model. For example:
class AllowedContentViewModelTests: XCTestCase {
func testDefaultState() {
let viewModel = AllowedContentViewModel(allowed: [], flags: [])
XCTAssert(!viewModel.isSelected(row: 0))
XCTAssert(!viewModel.isSelected(row: 1))
XCTAssert(viewModel.isSelected(row: 2))
XCTAssert(viewModel.isSelected(row: 3))
}
func testTogglingNsfw() {
var viewModel = AllowedContentViewModel(allowed: [], flags: [.nsfw])
XCTAssert(viewModel.isSelected(row: 0))
viewModel.toggle(row: 0)
XCTAssert(!viewModel.isSelected(row: 0))
}
}
See example at https://github.com/robertmryan/RefactoredViewController.
.swiftlint.yml
that I use to tailor it to my personal programing needs, but you should feel free to tweak to your personal preferences. But tools like this can be especially helpful if you’re new to Swift and want to follow established conventions. \$\endgroup\$