I have the following form that creates a new "activity":
For which I want to enforce the following requirements:
- Name => minimum # of chars: 1; maximum # of chars: 50
- Description => maximum # of chars: 200
I have written the following code:
// ActivityDetailViewController.swift
import UIKit
private struct Constants {
static let activityNameMaxCharacters = 50
static let activityDescriptionMaxCharacters = 200
}
final class ActivityDetailViewController: NiblessViewController {
/* ... */
// This method is called when user taps on the "Add" bar button item
// from the navigation bar.
@objc
func save(_ sender: UIBarButtonItem) {
do {
try validateInputs()
activity.name = nameField.text ?? ""
activity.activityDescription = descriptionTextView.text
presentingViewController?.dismiss(animated: true, completion: onDismiss)
} catch let error as ErrorMessage {
present(errorMessage: error)
} catch {
// NO-OP
}
}
/* ... */
// This methods applies the input validation rules.
private func validateInputs() throws {
guard let activityName = nameField.text else {
let errorMessage = ErrorMessage(
title: "Activity Creation Error",
message: "Activity name can't be empty."
)
throw errorMessage
}
let activityDescription = descriptionTextView.text ?? ""
if activityName.count > Constants.activityNameMaxCharacters {
let errorMessage = ErrorMessage(
title: "Activity Creation Error",
message: "Activity name exceeds max characters (50)."
)
throw errorMessage
} else if activityDescription.count > Constants.activityDescriptionMaxCharacters {
let errorMessage = ErrorMessage(
title: "Activity Creation Error",
message: "Activity description exceeds max characters (200)."
)
throw errorMessage
}
}
}
Some supporting code:
// ErrorMessage.swift
import Foundation
public struct ErrorMessage: Error {
// MARK: - Properties
public let id: UUID
public let title: String
public let message: String
// MARK: - Methods
public init(title: String, message: String) {
self.id = UUID()
self.title = title
self.message = message
}
}
extension ErrorMessage: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
}
// UIViewController+ErrorPresentation.swift
import UIKit
extension UIViewController {
public func present(errorMessage: ErrorMessage) {
let errorAlertController = UIAlertController(
title: errorMessage.title,
message: errorMessage.message,
preferredStyle: .alert
)
let okAction = UIAlertAction(title: "OK", style: .default)
errorAlertController.addAction(okAction)
present(errorAlertController, animated: true, completion: nil)
}
}
I'd appreciate any feedback on how to improve the current solution.
Things I don't like:
- Having to add an empty
catch
at the end of the try-catch clause located in thesave(_:)
method. Otherwise the compiler complains with: "Errors thrown from here are not handled because the enclosing catch is not exhaustive."
1 Answer 1
One can make the error conform to LocalizedError
:
// MARK: - ActivityCreationError
enum ActivityCreationError: Error {
case empty
case nameTooLong
case descriptionTooLong
}
// MARK: LocalizedError conformance
extension ActivityCreationError: LocalizedError {
var errorDescription: String? {
switch self {
case .empty:
return NSLocalizedString("The name cannot be empty.", comment: "MyError.empty")
case .nameTooLong:
return String.localizedStringWithFormat(
NSLocalizedString("The name is too long (max %d characters).", comment: "MyError.nameTooLong"),
Constants.activityNameMaxCharacters
)
case .descriptionTooLong:
return String.localizedStringWithFormat(
NSLocalizedString("The description is too long (max %d characters).", comment: "MyError.descriptionTooLong"),
Constants.activityDescriptionMaxCharacters
)
}
}
}
// MARK: Public interface
extension ActivityCreationError {
static let title = NSLocalizedString("Activity Creation Error", comment: "MyError.title")
}
Notice that in my error messages, in the spirit of keeping it DRY, I avoided hardcoding the max string lengths again in the error message, but instead used localizedStringWithFormat
to pull the same constant that was used during the validation process.
Then you can just catch it and use its localizedDescription
:
do {
try validateInputs()
...
} catch {
presentAlert(title: ActivityCreationError.title, message: error.localizedDescription)
}
Where:
extension UIViewController {
public func presentAlert(title: String?, message: String?) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
let string = NSLocalizedString("OK", comment: "Alert Button")
let action = UIAlertAction(title: string, style: .default)
alert.addAction(action)
present(alert, animated: true)
}
}
By the way, before confirming validating the input and confirming string lengths, I would suggest sanitizing the input to trim whitespace. You do not want, for example, a user saying "oh, the name is required? ... well, I'll get around that by just entering a single space in that field." You probably want the whitespace removed, anyway, as you do not want, for example, input with leading or trailing spaces.
E.g., rather than:
let activityDescription = descriptionTextView.text ?? ""
Use:
let activityDescription = descriptionTextView.text.?trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
If you are looking for more advanced counsel, one might adopt a better separation of responsibilities and pull the validation logic out of the view controller altogether. You might put it in what some people call the "view model" or the "presenter" or simply the "controller" (not to be confused with the "view controller"), a UIKit independent object in which you place your business logic (e.g. validation rules, initiate interactions with data stores or network managers, etc.).
For more information, see:
See Dave Delong’s A Better MVC for a perspective of embracing MVC but employing techniques to keep them small and manageable.
See Medium’s iOS Architecture Patterns for a review of other approaches ranging from MVP, MVVM, Viper, etc.
See Krzysztof Zabłocki’s iOS Application Architecture for another good discussion on the topic.
The common theme in all of these discussions is to avoid conflating the "view" (the configuration of and interaction with UIKit controls) and the business business rules.
-
\$\begingroup\$ Thank you!! This is a fantastic improvement to my code. May I ask what's the purpose of the "comment" parameter that NSLocalizedString(_:comment:) expects? The docs don't give any explanation whatsoever. \$\endgroup\$Enrique– Enrique2022年02月09日 16:58:01 +00:00Commented Feb 9, 2022 at 16:58
-
\$\begingroup\$ Also, since the
Constants
enum is private to my view controller; I could not access it fromActivityCreationError.swift
. So I decided to add a "maxCharacters" associated value to the "nameTooLong" and to the "descriptionTooLong" cases. That way, I can pass the maximum number of characters from the view controller to the enum cases when instantiating them. \$\endgroup\$Enrique– Enrique2022年02月09日 17:09:07 +00:00Commented Feb 9, 2022 at 17:09 -
1\$\begingroup\$ As an aside, to my last point in my answer, none of this validation code really belongs in a view controller. But setting that aside, as long as you don’t repeat the 50/200 values, that is the primary goal. \$\endgroup\$Rob– Rob2022年02月09日 18:06:39 +00:00Commented Feb 9, 2022 at 18:06
-
1\$\begingroup\$ "what's the purpose of the ‘comment’ parameter?" ... often, when you localize an app, the translator is not looking at your code, but just a list of English phrases and the "comment". The comment gives them a hint about the context where their translation will be used. \$\endgroup\$Rob– Rob2022年02月09日 23:47:45 +00:00Commented Feb 9, 2022 at 23:47