Property wrappers, also known as property delegates, have been introduced in Swift 5.1 and new frameworks like SwiftUI and Combine heavily rely on them, hence the importance of understanding how they works.
What is exactly a property wrapper?
A property wrapper is a type that can wrap a property adding functionalities without changing the type of the wrapped property. Some examples defined in the frameworks mentioned above are @State
, @ObservedObject
and @Published
.
An example in the real world would be an invisible box around a ball that plays a note of the Beethoven ninth symphony every time the ball is kicked without changing the ball physical properties (mass, density, etc.).
In order to better understand how they work let’s try to build a form that allows users to type an amount of money and send them to another user.
Assuming the amount can’t be less than 0 or more than 1000, the code that solves the problem without property wrappers can roughly look like this
class TypeAmountViewController: UIViewController {
@IBOutlet private var amountTextField: UITextField!
private var amount = 0
@IBAction func confirmAmount() {
amountTextField.resignFirstResponder()
validateAmount()
}
private func validateAmount() {
guard
let typedAmount = Int(amountTextField.text ?? "")
else {
showErrorAlert()
return
}
amount = min(1_000, max(0, typedAmount))
if amount > 0 {
showConfirmAmountAlert()
}
}
private func showConfirmAmountAlert() {
print("Are you sure to send \(amount)?")
}
private func showErrorAlert() {
print("The value you typed doesn't look like a number")
}
private func handleConfirmedAmount() {
print("You sent \(amount)!")
}
}
A downside of this approach is that the clamping logic is embedded in the view controller making it difficult to reuse the code.
To solve this problem it is possible to introduce a new type
struct Amount {
let value: Int
init(value: Int, range: Range<Int>) {
self.value = min(range.upperBound, max(range.lowerBound, value))
}
}
And the view controller will now look like this
class TypeAmountViewController: UIViewController {
@IBOutlet private var amountTextField: UITextField!
private var amount = Amount(value: 0, range: 0..<1_000)
@IBAction func confirmAmount() {
amountTextField.resignFirstResponder()
validateAmount()
}
private func validateAmount() {
guard
let typedAmount = Int(amountTextField.text ?? "")
else {
showErrorAlert()
return
}
amount = Amount(value: typedAmount, range: 0..<1_000)
if amount.value > 0 {
showConfirmAmountAlert()
}
}
private func showConfirmAmountAlert() {
print("Are you sure to send \(amount.value)?")
}
private func showErrorAlert() {
print("The value you typed doesn't look like a number")
}
private func handleConfirmedAmount() {
print("You sent \(amount.value)!")
}
}
The main pitfall of this code is that amount
is no more a Int
, therefore it is not possible to sum, compare and perform all the other operations on it allowed with other integers, plus in order to obtain the real amount we need to read the property amount.value
.
Let’s see how it is possible to use property wrappers to solve this issue.
In order to tell the compiler that “a type is a property wrapper” we need to:
- Add the keyword
@propertyWrapper
before the type declaration - Declare at least one
wrappedValue
property.
In the example case we need to wrap an integer amount, therefore wrappedValue
is an Int
.
In the init we still need the range to be sure the amount doesn’t exceed our requirements.
The final result will look like this:
@propertyWrapper // <- the type `Amount` is a property wrapper
struct Amount {
// every time amount is set (eg: amount = 5), amount.wrappedValue is set (amount.wrappedValue = 5)
var wrappedValue: Int { // <- the type we need to wrap is an Int
set {
costrainedValue = min(range.upperBound, max(range.lowerBound, newValue))
}
get {
costrainedValue
}
}
private var costrainedValue: Int
private let range: Range<Int>
init(wrappedValue: Int, range: Range<Int>) {
self.costrainedValue = wrappedValue
self.range = range
}
}
Now it is possible to treat amount
as an Int
while still limit its value using the following declarative API
@Amount(range: 0..<1_000)
private var amount = 0
As you can the init
takes two parameters as input (one wrappedValue: Int
and one range: Range<Int>
) and in the declaration we only specify the range but the compiler is smart enough to understand that our wrapped value is 0.
It is possible to use the property wrapper in our view controller like this
class TypeAmountViewController: UIViewController {
@IBOutlet private var amountTextField: UITextField!
@Amount(range: 0..<1_000)
private var amount = 0
@IBAction func confirmAmount() {
amountTextField.resignFirstResponder()
validateAmount()
}
private func validateAmount() {
guard
let typedAmount = Int(amountTextField.text ?? "")
else {
showErrorAlert()
return
}
self.amount = typedAmount
if amount > 0 {
showConfirmAmountAlert()
}
}
private func showConfirmAmountAlert() {
print("Are you sure to send \(amount)?")
}
private func showErrorAlert() {
print("The value you typed doesn't look like a number")
}
private func handleConfirmedAmount() {
print("You sent \(amount)!")
}
}
Now it is possible to use @Amount
anywhere in the code where we need an integer property constrained between two values but what about Float
or Double
numbers? Do we need to write a property wrapper for each type we want to wrap?
Of course not. One of the best features of Swift are generics, in the next posts I’ll show you how to use generics and how to build a generic property wrapper.