Post

Property wrappers personalizados

Los property wrappers es una funcionalidad disponible desde Swift 5.1 que permiten asociar lógica cuando las propiedades cambian. Esencialmente envuelven el valor original añadiendo funcionalidades. Se pueden implementar como struct o class al añadir el atributo @propertywrapper. Para conformarse deben incluir una propiedad calculada llamada wrappedValue. Es en esta propiedad en la que se implementa la lógica al asignar un valor a la propiedad o cuando es invocada.

Os traigo el ejemplo de un property wrapper que se encargará de manejar la lectura y escritura de un dato tipo Codable en un archivo JSON en el directorio de documentos de la app.

Para poder codificar y decodificar la información se requiere que el tipo de dato cumpla con el protocolo Codable

DocStorage

@propertyWrapper
struct DocStorage<Value: Codable> { //.. 

Solicitaremos un nombre para el archivo donde se almacenará el valor, así que incluimos esa propiedad y una ruta, que hemos predefinido que sera URL.documentsDirectory

//..
var filename: String 
private var url: URL
//..

Debemos añadir la propiedad wrappedValue de tipo Codable, pero del mismo tipo que el asignado, es por ello que el tipo es Value. Esa propiedad empaquetará una lógica al ser leída y asignada, por ello el get y el set

//..
var wrappedValue: Value? {
        get {
           loadJsonFromDocuments()
        }
        set {
           saveJsonToDocuments(newValue)
        }
    }
//..

newValue es un valor opcional proporcionado por el set de manera automática, es el “nuevo valor” asignado a nuestra propiedad.

Implementamos las funciones que se encargarán de la lógica cuando se lea o escriba.

//..
private func loadJsonFromDocuments() -> Value? {
        guard let data = try? Data(contentsOf: url),
              let value = try? JSONDecoder().decode(Value.self, from: data) else { return nil }
        return value
    }
    
    private func saveJsonToDocuments(_ value: Value?) {
        if let value {
            let data = try? JSONEncoder().encode(value)
            try? data?.write(to: url, options: .atomic)
        }
    }
//..

El init deberá solicitar cómo el nombre de ese archivo y añadirlo a la url. Si existe un archivo en esa ruta con ese nombre se cargará y decodificará para asignarlo a la “propiedad empaquetada”.

  init(wrappedValue: Value?, fileName: String) {
        self.fileName = fileName
        url = URL.documentsDirectory.appendingPathComponent(fileName, conformingTo: .json)
        if FileManager.default.fileExists(atPath: url.absoluteString) {
            self.wrappedValue = loadJsonFromDocuments()
        }
   }
}

Uso

En nuestro View Model definimos nuestras propiedades empaquetadas.

Aquí es donde debemos declarar el tipo de dato, en fileName el nombre del archivo y el nombre de la propiedad. En este caso se asigna un Array vacío en caso de que al ejecutar el get haya devuelto nil

final class DocViewModel: ObservableObject {
    @DocStorage<[Mobile]>(fileName: "mobiles") var mobilesFile = []
    @DocStorage<[SmartTv]>(fileName: "smartTvs") var smartTvsFile = []
//..

Definir las propiedades @Published que serán utilizas por la view. En este caso llevan el observador de propiedad didSet, en la que cada vez que haya un cambio, éste lo asignará a nuestro propertywrapper @DocStorage

//..
  @Published var mobiles: [Mobile] = [] {
        didSet { mobilesFile = mobiles }
  }
    
  @Published var tvs: [SmartTv] = [] {
        didSet { smartTvsFile = tvs }
  }
    //..

Finalmente cuando el DocViewModel se inicialice deberá asignar los valores de @DocStorage a nuestro @Published

//..
 init() {
        loadFrom(docStorage: mobilesFile, to: &mobiles)
        loadFrom(docStorage: smartTvsFile, to: &tvs)
 }
//..

La función puede inferir de la propiedad a la que vamos a asignar el tipo de dato. Hay que tener en cuenta que el valor de property es inout, que indica que el valor que pasamos a la función puede ser modificado y que dichos cambios se verán reflejados en esa misma variable. Es decir pasamos la referencia de la propiedad y no una copia por valor.

Para hacer explicito, en la invocación de la función añadimos el modificador &

 //..
  private func loadFrom<J: Codable>(docStorage: J?, to property: inout J) {
        if let docStorage {
            property = docStorage
        }
    }
//..

Con ello tenemos una propiedad que carga y persiste los datos en disco, que va a ser invocada al inicializarse el DocViewModel y cuando haya una modificación en los datos de la propiedad @Published.

Hemos visto como crear un propertywrapper personalizado, que tiene una funcionalidad equivalente a @AppStorage (UserDefaults).

Podemos hacer nuestros property wrappers por ejemplo para validar los datos de entrada, añadir un formato específico, cifrar y descifrar valores o guardar y recuperar del KeyChain.

Espero te haya sido útil y comiences a incluir property wrappers o empaquetadores de propiedades en tus proyectos.

Aquí tienes el código completo

@propertyWrapper
struct DocStorage<Value:Codable> {
    var fileName: String
    private var url: URL
    
    init(wrappedValue: Value?, fileName: String) {
        self.fileName = fileName
        url = URL.documentsDirectory.appendingPathComponent(fileName, conformingTo: .json)
        if FileManager.default.fileExists(atPath: url.absoluteString) {
            self.wrappedValue = loadJsonFromDocuments()
        }
    }
    
    var wrappedValue: Value? {
        get { loadJsonFromDocuments() }
        set { saveJsonInDocuments(newValue) }
    }
    
    private func loadJsonFromDocuments() -> Value? {
        guard let data = try? Data(contentsOf: url),
              let value = try? JSONDecoder().decode(Value.self, from: data) else { return nil }
        return value
    }
    
    private func saveJsonInDocuments(_ value: Value?) {
        if let value {
            let data = try? JSONEncoder().encode(value)
            try? data?.write(to: url, options: .atomic)
        }
    }
}

final class DocViewModel: ObservableObject {
    @DocStorage<[Mobile]>(fileName: "mobiles") var mobilesFile = []
    @DocStorage<[SmartTv]>(fileName: "smartTvs") var smartTvsFile = []
    @DocStorage<String>(fileName:"cadena") var cadena = ""
    @Published var mobiles: [Mobile] = [] {
        didSet { mobilesFile = mobiles }
    }
    @Published var tvs: [SmartTv] = [] {
        didSet { smartTvsFile = tvs }
    }
    
    init() {
        loadFrom(docStorage: mobilesFile, to: &mobiles)
        loadFrom(docStorage: smartTvsFile, to: &tvs)
    }
}