¿Cómo funciona SwiftUI?

SwiftUI nos permite presentar vistas en la pantalla a través de su estilo de programación declarativa. Para ello se apoya en el protocolo View, que es el que da la capacidad a cualquier tipo de dato de ser presentado en pantalla.

¿Qué es una vista?

Si venimos de desarrollar proyectos con UIKit debemos saber que hay dos componentes básicos que componen cada pantalla:

  • UIViewController. Podríamos definirlo como el controlador de la pantalla. Aquí vamos a encontrar el código que se va a encargar de manejar todo lo que se hace en la pantalla. Esta clase en sí no es una vista, pero su propia definición incluye la propiedad view, la cual se mostrará cuando se indique que se debe presentar.
  • UIView. Es el nivel más básico de un elemento visual. Cualquier otro elemento que se muestre en pantalla extenderá en última instancia de esta clase. Esta clase necesita estar contenida en un UIViewController para poder ser presentada.

En SwiftUI no existe esta diferencia, y el único nivel que tenemos es el protocolo View. Esto quiere decir que cualquier elemento que implemente el protocolo View podrá ser presentado en pantalla de forma directa (elemento de primer nivel) o indirecta (embebido en otra vista). Esto resulta muy útil a la hora de reutilizar vistas porque permite que cualquier vista pueda ser presentada o embebida sin distinciones.

La definición del protocolo View en la SDK del sistemas es la siguiente:

public protocol View {

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required `body` property.
    associatedtype Body : View

    /// The content and behavior of the view.
    var body: Self.Body { get }
}

Como se puede ver, el protocolo View solo tiene como requisito la implementación del parámetro body y este debe devolver un tipo some View. Este parámetro será una variable computada y el sistema se encargara de solicitarla cuando tenga que mostrar la vista o cambie el estado de la misma.

Esta forma de declarar vistas nos permite ser muy flexibles a la hora de componer la información que queremos mostrar en cada punto de la app, ya que nuestro objetivo será crear componentes visuales que iremos reutilizando en otras vistas para formar cada una de las pantallas de la app.

Anteriormente, cuando se diseñaban las vistas de las apps se hacía una separación de vistas de controlador y vistas de celdas como mínimo. También se creaban vistas para añadir a través de containers a código, pero la forma de componer las vistas de UIKit no invitaba a ello (sobre todo si no trabajabas con storyboards).

Sin embargo, con SwiftUI hay que orientar los desarrollos pensando que cada vista se encargará de manejar un dato atómico, y será independiente para conseguir este fin, siendo una parte de la pantalla y no todo el conjunto, por lo que nos permitirá reutilizar la vista en varios puntos de la aplicación.

¿Cómo se crea una vista?

Tal y como acabamos de ver, el único requisito para la creación de una vista es que nuestro tipo de dato implemente el protocolo View. Por lo tanto, solamente tenemos que implementar el parámetro body y devolver un View. Para ello usaremos todos los objetos que nos proporciona el propio framework de SwiftUI o nuestros propios componentes que implementen dicho protocolo para componer nuestro View, que será el que se presente en pantalla.

La sintaxis a la hora de crear una vista está muy ligada al uso de Syntactic sugar, que nos ayuda a que el código sea mas legible. ¿Qué quiere decir esto? Pues que muchos de los componentes que usemos son tipos de Swift que tienen un método ‘init’ con o sin parámetros, donde el último de sus parámetros es un closure @ViewBuilder que se encarga de componer la propia vista. Al tratarse de un closure el último parámetro, tenemos varias formas de realizar la sintaxis del código.

Por ejemplo, vamos a ver la definición el tipo Group según la SDK de SwiftUI:

@frozen public struct Group {

    /// The type of scene that represents the body of this scene.
    ///
    /// When you create a custom scene, Swift infers this type from your
    /// implementation of the required ``SwiftUI/Scene/body-swift.property``
    /// property.
    public typealias Body = Never
}

extension Group : View where Content : View {

    @inlinable public init(@ViewBuilder content: () -> Content)
}

Según esta definición, el tipo Group tiene un método init con un closure que devuelve un tipo Content, y que a su vez Content es un View. Esto nos indica que para usarlo tenemos que tener un código como el siguiente:

struct ContentView: View {
    var body: some View {
        Group(content: {
            Text("Hello world!")
        })
    }
}

Esto puede ser resumido gracias a Syntactic sugar, que nos muestra que si el último parámetro de una función es un closure podemos omitir ese parámetro y declararlo como si fuera una función, dejándonos el siguiente resultado:

struct ContentView: View {
    var body: some View {
        Group {
            Text("Hello world!")
        }
    }
}

Finalmente, esto nos deja un código mucho más limpio y mejora su legibilidad. Por lo general, todos los componentes de SwiftUI seguirán esta forma de nomenclatura, aunque a veces tengan más parámetros como, por ejemplo, un Button:

struct ContentView: View {
    var body: some View {
        Group {
            Button("Do Something") {
                print("Done!")
            }
        }
    }
}

Hay que tener en cuenta que cada componente tendrá su propio init, y habrá que adaptar su declaración dependiendo de cada uno.

Cambios de estado

En SwiftUI hay que programar cada vista dependiendo del estado que puedan tener. Los estados son el mecanismo que tiene el framework de manejar los cambios que ocurren en la pantalla, permitiendo modificar lo que se presenta o iniciar un nuevo flujo en nuestra app.

Hay varias formas de declarar variables de estado, dependiendo del contexto en el que nos encontremos, pero por ahora vamos a declarar una de tipo @State. Estas variables permiten que nuestra aplicación responda cuando modifican su valor, haciendo posible que cualquiera que dependa de su valor se actualice con cada modificación de la misma.

struct ContentView: View {
    @State private var showText: Bool = false
    
    var body: some View {
        VStack {
            Button("Change value") {
                showText.toggle()
            }
            if showText {
                Text("Hello world!")
            }
        }
    }
}

Este código mostrará y ocultará el texto “Hello world!” cuando pulsemos el botón “Change value”, ya que la implementación del tap del botón cambia el estado de la variable showText. Al ser esta una variable de estado, el propio framework de SwiftUI sabrá que tiene que volver a pintar el body de ContentView, ya que en su implementación se usa esta variable de estado.

Este modo de funcionamiento es básico del framework SwiftUI, por lo que lo vamos a encontrarlo en casi todo sus componentes, y muchos de los eventos que anteriormente en UIKit se manejaban con bloques o delegados pasarán a ser manejados con variables de estado.

Ejemplo práctico

Por ejemplo, imaginemos un formulario de login sencillo. Hay ocasiones en las que podemos hacer login desde mas de un punto de la aplicación, por lo que podemos extraer la funcionalidad básica del formulario en un componente.

struct LoginComponentView: View {
    @State var user: String = ""
    @State var password: String = ""
    
    var body: some View {
        VStack {
            TextField("User", text: $user)
                .textFieldLogin()
            SecureField("Password", text: $password)
                .textFieldLogin()
            Button("Login") {
                print("Do Login")
            }
            .padding(.top, 30)
            .foregroundColor((user.isEmpty || password.isEmpty) ? Color.black.opacity(0.6) : Color.white)
            .disabled(user.isEmpty || password.isEmpty)
            
        }.padding(.all, 10)
    }
}

extension View {
    public func textFieldLogin() -> some View {
        return self
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .background(Color.white)
            .frame(height: 40)
            .cornerRadius(5)
    }
}

Este componente tiene lo necesario para poder hacer el login del usuario y de esta forma lo podremos reutilizar en diferentes puntos de la aplicación rápidamente. Como vemos en la imagen, se ha puesto un control a través de las variables de estado user y password. Por ello, el botón “Login” estará deshabilitado hasta que no se haya escrito en los dos campos de texto.

Después creamos las pantallas necesarias donde usaremos este componente de login.

struct LoginFullScreenView: View {
    var body: some View {
        VStack {
            Spacer()
            Image("sdos_logo")
            Spacer()
            LoginComponentView()
            Spacer()
        }
        .background(Color.init(UIColor(red: 31/255, green: 155/255, blue: 222/255, alpha: 1)))
        .navigationBarTitle("", displayMode: .inline)
    }
}
struct LoginEmbedView: View {
    var body: some View {
        ScrollView {
            VStack {
                Image("sdos_office")
                    .resizable()
                    .frame(height: 200)
                    .scaledToFit()
                Text("La firma tecnológica sevillana SDOS instalará su nueva factoría de software en El Puerto")
                    .font(.title2)
                    .padding([.leading, .trailing], 10)
                Text("La consultora tecnológica SDOS ha anunciado que abrirá en El Puerto, en el polígono Las Salinas, una factoría de software. Y espera que esté operativa en el segundo semestre del año. Se trataría de un centro tecnológico...")
                    .font(.subheadline)
                    .padding([.leading, .trailing, .top], 10)
                Divider()
                Text("Para leer más identifícate")
                    .padding([.all], 10)
                LoginComponentView()
                    .padding([.leading, .trailing, .top], 10)
                    .background(Color.init(UIColor(red: 31/255, green: 155/255, blue: 222/255, alpha: 1)))
                Spacer()
                
            }
        }
        .background(Color.gray.opacity(0.1))
        .navigationBarTitle("", displayMode: .inline)
    }
}

Por último, creamos un primer listado a través del cual nos permitirá navegar a estas pantallas. Esta deberá ser la pantalla de inicio de nuestra aplicación.

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                NavigationLink("Full Screen", destination: LoginFullScreenView())
                NavigationLink("Embed", destination: LoginEmbedView())
            }.navigationBarTitle("Login Options", displayMode: .inline)
        }
    }
}

Como resultado tendremos la siguiente aplicación:

Personalización de vistas

La personalización de las vistas se realiza usando los métodos definidos en cada componente View que usemos. La gran mayoría de métodos se encuentran en el propio protocolo View (también encontramos métodos propios sobre cada componente en algunos casos como, por ejemplo, en Text), lo que nos permite asignar estilos sobre las vistas que no se ven reflejados en el propio componente, pero si en sus hijos, ya que las modificaciones se propagan a los hijos.

Con todo esto queremos decir que, si modificamos la propiedad font de un VStack, no vamos a ver ningún cambio, ya que este componente no tiene texto. Sin embargo, si incluimos en su interior algún componente que contenga texto (como Text), veremos cómo se ve afectado por esa propiedad font que tiene asignada el VStack.

Todos los métodos de personalización se invocan sobre una instancia de cualquier componente que implemente View y nos da acceso a sus métodos de personalización de su propio tipo y a los genéricos de View. Cada uno de estos métodos, a su vez, devolverán el propio tipo de la instancia o el protocolo View, y nos permitirá concatenar nuevos métodos para modificar las propiedades de las vistas de forma ilimitada.

Por ejemplo, el componente Text tiene una serie de métodos de personalización que sobreescriben a los propios del protocolo View.

Definición de foreground en Text.

func foregroundColor(_ color: Color?) -> Text

Definición de foreground en View

func foregroundColor(_ color: Color?) -> some View

Esto es así para que podamos seguir aplicando estilos propios de Text concatenando nuevos métodos, pero teniendo en cuenta que cuando usemos un método que devuelva View perderemos la referencia al tipo Text.

También podemos crear métodos de personalización propios para las vistas simplemente creando una extensión del protocolo View e implementando los métodos que queramos. Una muestra de ello puede ser  el usado para los TextField en este ejemplo:

extension View {
    public func textFieldLogin() -> some View {
        return self
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .background(Color.white)
            .frame(height: 40)
            .cornerRadius(5)
    }
}

Si lo deseas, puedes encontrar este ejemplo en https://github.com/SDOSLabs/SwiftUI-Test bajo el apartado Login.

Rafael Fernández,
iOS Tech Lider