Ghostboard pixel

Swift: Property wrappers

Swift: Property wrappers

Property wrappers enable you to reuse code that specifies the access pattern of a property.

Often you add behaviour to a property through its observers. For example, you can use the didSet observer to trim a string:

class TestClass {

    var trimmedString: String = "" {
        didSet {
            trimmedString = trimmedString.trimmingCharacters(in: .whitespaces)
        }
    }
    
}

var test = TestClass()
test.trimmedString = "    test"
print(test.trimmedString) //"test"

This is very useful. However, if you want to add this behaviour to several properties, you have to implement this behaviour separately for each property. Instead, you can define a so called property wrapper.

You create a property wrapper by creating a struct, class or enum that uses the @propertyWrapper annotation. Inside the property wrapper you have to implement the wrappedValue property. By implementing property observers of wrappedValue you can add custom behaviour.

Note that observers of stored properties (didSet, willSet) get not called at initialisation time. Hence, you also have to implement the init method.

Let’s create a property wrapper for the use case of trimming a string:

@propertyWrapper struct WhiteSpacesTrimmed {
        
    var wrappedValue: String {
        didSet {
            print("propertyWrapper didSet")
            wrappedValue = wrappedValue.trimmingCharacters(in: .whitespaces)
        }
    }
    
    init(wrappedValue:String) {
        self.wrappedValue = wrappedValue.trimmingCharacters(in: .whitespaces)
    }
    
}

A property wrapper can then be applied to every property that has the corresponding type:

class TestClass {
    @WhiteSpacesTrimmed var test = ""
    @WhiteSpacesTrimmed var test2 = ""
}

let testClass = TestClass()
testClass.test = "    test"
print(testClass.test) //"test"

As you can see, you just have to add the annotation @WhiteSpacesTrimmed.

wrappedValue as a computed property

In the last example, the wrapped value was a stored property. It’s also possible to use a computed value:

@propertyWrapper struct Max100 {
    
    private var value: Int = 0
    
    var wrappedValue: Int {
        get {
            return value
        }
        set {
            value = min(100, newValue)
        }
    }
    
    init(wrappedValue: Int) {
        self.wrappedValue = wrappedValue
    }
    
}

class TestClass2 {
    @Max100 var test = 0 
}

let testClass2 = TestClass2()
testClass2.test = 300
print(testClass2.test)

In this example we created a property wrapper that limits an integer property to the maximum value of 100.

Customizing a property wrapper

As you have seen in the last example, a property wrapper can have properties of its own. But it’s also possible to initialise these properties with custom values every time you use the property wrapper.

In the following example we create a property wrapper that writes and saves a string value to the user defaults. The name of the user defaults key is customizable:

@propertyWrapper
struct UserDefaultsWrapper {
    
    var key: String
    
    var wrappedValue: String? {
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
        get {
            UserDefaults.standard.string(forKey: key)
        }
    }
    
}

class TestClass3 {
    @UserDefaultsWrapper(key: "testKey") var test: String?
}

If your property wrapper has properties, they can be initialised. If they have an initial value, they’re are optional. If they lack an initial value, they are mandatory.

Projected values

Besides wrappedValue there is another special property inside a property wrapper: projectedValue. This value is accessible from the outside by using the symbol $. Hence, you can use it to provide additional functionality.

In the following example we again create a property wrapper that trims a string. But in this case we save the initial value inside the projectedValue property:

@propertyWrapper struct WhiteSpacesTrimmed {
        
    var projectedValue: String
    
    var wrappedValue: String {
        didSet {
            self.projectedValue = wrappedValue
            wrappedValue = wrappedValue.trimmingCharacters(in: .whitespaces)
        }
    }
    
    init(wrappedValue:String) {
        self.projectedValue = wrappedValue
        self.wrappedValue = wrappedValue.trimmingCharacters(in: .whitespaces)
    }
    
}

class TestClass5 {
    @WhiteSpacesTrimmed var test = "       test"
}

print("projected Values")
var testClass5 = TestClass5()
print(testClass5.test) //"test"
print(testClass5.$test) //"       test"

References

Title image: @ Foxys Forest Manufacture / shutterstock.com