Tutoriales

Reloj de agujas con SwiftUI y Combine

Descubre el potencial de SwiftUI y Combine con un ejemplo muy vistoso.

Resumen del artículo
  • Te enseñamos paso a paso a crear la app de un reloj analógico en SwiftUI, usando Combine para controlar el tiempo.

En la pasada WWDC, Apple sorprendió a propios y extraños presentando su nuevo framework de programación de interfaces: SwiftUI.

Compatible con todos sus sistemas, declarativo, orientado a protocolos… y adaptado al estado actual de la tecnología. Desde que en septiembre se lanzasen la versiones finales de sus nuevos sistemas operativos ya es posible crear aplicaciones completas utilizando SwiftUI. Vamos a ver de lo que este framework es capaz con unas pocas líneas de código.

Creamos el proyecto

Aunque nos encontramos en plena era digital, hoy vamos a implementar un reloj analógico. Para facilitar su desarrollo y poder hacerlo extensible al iPad, vamos a utilizar Playgrounds, que ya está disponible también para macOS. Así que el primer paso será abrir la aplicación Playgrounds en nuestro Mac o iPad y crear un nuevo proyecto.

Después, importaremos los frameworks básicos para comenzar: uno para la creación de las interfaces y otro para poder ver éstas en vivo.

import SwiftUI
import PlaygroundSupport

El siguiente paso es crear una vista básica de SwiftUI, una View, para contener nuestro reloj. Posteriormente mostraremos nuestra vista base en la previsualización del Playground. La variable body contiene la parte de la vista que se renderizará y en el snippet siguiente dará una error, pero enseguida lo vamos a arreglar.

struct ContenedorView: View {
    var body: some View {
        FondoView(ancho: 40, espacio: 20)
    }
}

PlaygroundPage.current.setLiveView(ContenedorView())

Podemos utilizar directamente la funciónsetLiveView y dejar de lado el antiguoUIHostingController para alojar nuestra vista de SwiftUI.

Empezamos por el fondo

Los primero es fijar el fondo de nuestro reloj. Vamos a crear una nueva vista para ello.

struct FondoView: View {
    var ancho: CGFloat
    var espacio: CGFloat
    
    var body: some View {
        GeometryReader { geo in
            Circle()
                .stroke(style: StrokeStyle(
                    lineWidth: self.ancho,
                    dash: [self.espacio, self.espaciado(para: geo.size.width, con: self.espacio)],
                    dashPhase: self.espacio / 2
                ))
                .padding(self.ancho / 2)
        }
        .padding(52)
    }
    
    private func espaciado(para diámetro: CGFloat, con espacio: CGFloat) -> CGFloat {
        return ((diámetro - ancho) * CGFloat(Double.pi) - espacio * 12) / 12
    }
}

La base del fondo es un círculo de tipo Circle pero que dividimos en sectores que serán las marcas que indican las horas del reloj. Estos se hacen pasando como estilo del círculo un StrokeStyle que recibe tres parámetros:

  • lineWidth indica el ancho de la corona circular. Vamos a hacer que sea controlable mediante la variable ancho. Esta dimensión se aplica tanto hacia el interior como el exterior por lo que hemos añadido un padding a nuestro Circle que controla que no se salga de su contenedor.
  • dash controla la separación de los sectores. El primer elemento es el que va relleno de color y el siguiente es la separación entre éstos. Utilizamos la función espaciado para calcular la separación necesaria para el valor del primer elemento que estamos controlando con la variable espacio. Para ello, gracias al GeometryReader se obtiene el ancho total disponible para hacer el cálculo, ya que este tipo nos permite conocer mediante su propiedad size el tamaño de la vista que pasamos como closure. Este será la longitud del círculo (en el punto medio de la corona) menos los doce espacios rellenos que ya hemos fijado y dividido entre los doce espacios vacíos que queremos calcular.
  • dashPhase marca el comienzo del patrón, lo estamos adelantado la mitad de la longitud del sector para que el punto que marca las doce en el reloj cuadre en la mitad de uno de los sectores.

El color negro queda bastante soso así que vamos a utilizar un degradado para alegrarlo un poco. Volvemos a nuestro ContenedorView, eliminamos todo el body y añadimos lo siguiente:

AngularGradient(
            gradient: Gradient(colors: [.blue, .green, .yellow, .red]),
            center: .center
        )
            .mask(FondoView(ancho: 40, espacio: 20))
            .rotationEffect(Angle(degrees: -90))

SwiftUI es un cambio grande respecto a la forma de pensar que teníamos con UIKit, y en este bloque se puede apreciar claramente.

Lo que hemos hecho es crear una vista con gradiente de color AngularGradient de cuatro colores, cuyo centro del gradiente está en el centro de la propia vista. Y después aplicamos una máscara con el fondo que hemos creado anteriormente. Por último rotamos 90 grados negativos ya que el gradiente comienza en la mitad del lado derecho de la vista y nosotros queremos que comience en la mitad del extremo superior.

Notar aquí que giramos todo el conjunto y SwiftUI cambia el orden de llamada a las funciones para modificar el momento en el que se aplica. Esta vez no altera el resultado porque lo que está contenido en el mask se queda igual al rotar, pero en otros ejemplos quizás sería necesario anteponer la rotación a la aplicación de la máscara.

Marcamos las horas

¿Un reloj no debería marcar las horas aunque enloquezcamos? Ahora mismo lo solucionamos.

Lo primero que vamos a hacer es volver a la vista ContenedorView y envolver el fondo de nuestro reloj, que habíamos creado previamente, en un ZStack para ir añadiendo más elementos. Esta forma de apilar se aplica en el eje Z por lo que el primer elemento estará en la base, el segundo encima, el siguiente encima de los anteriores…

struct ContenedorView: View {
    var body: some View {
        ZStack {
            AngularGradient(
                gradient: Gradient(colors: [.blue, .green, .yellow, .red]),
                center: .center
            )
                .mask(FondoView(ancho: 40, espacio: 20))
                .rotationEffect(Angle(degrees: -90)

             HorasView()
        }
    }
}

Añadimos al stack la vista HorasView que mostrará la horas y que construimos de la siguiente forma.

struct HorasView: View {
    var body: some View {
        GeometryReader { geo in
            ForEach(0...11, id: \.self) { i in
                Text("\(i != 0 ? i : 12)")
                    .font(.largeTitle).bold()
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
                    .rotationEffect(.degrees(Double(i) * 30))
            }
            .padding((geo.size.height - geo.size.width) / 2)
        }
    }
}

En este bloque de código hemos creado 12 vistas, cada una con un número que va del 1 al 12. Esto es porque en el primer caso, cuando es 0, lo sustituimos para coincidir con los valores de las horas de un reloj clásico. Cambiamos la fuente de la letra, la hacemos negrita y estiramos la vista para que llegue de arriba hasta abajo. Finalmente las giramos de 30 en 30 grados y ya tenemos cada hora colocada en su sitio.

Es el turno de las agujas

Una vez que hemos finalizado el fondo y los elementos base, llega el momento de poner agujas en nuestro reloj. Las vamos añadiendo en el ZStack del contenedor: segundero, minutero, aguja de la hora y un punto central que hará como una especie de pieza donde se insertan las agujas. Empezaremos por este último.

Circle()
    .frame(width: 42, height: 42)

Ya tenemos nuestro punto central el que irán insertadas las agujas. Uno de los puntos fuertes de SwiftUI es la facilidad de crear vistas con código que se puedan reutilizar de forma sencilla. Así que crearemos una vista común para todas las agujas.

struct AgujaView: View {
    let valor: Int
    var proporciónRadio: CGFloat
    var ancho: CGFloat
    var grados: Double
    
    var body: some View {
        GeometryReader { geo in
            Rectangle()
                .frame(width: self.ancho, height: (geo.size.width * self.proporciónRadio) / 2)
                .offset(x: 0, y: -(geo.size.width * self.proporciónRadio) / 4)
                .rotationEffect(.degrees(Double(self.valor) * self.grados))
        }
    }
}

Ahora, para crear una aguja, tenemos que iniciar la vista anterior enviando los siguientes parámetros:

  • valor los segundos, minutos u horas que señala.
  • proporciónRadio la longitud de la aguja en base a la proporción que guarda con el radio total del reloj.
  • ancho para nuestra aguja.
  • grados los grados que equivale a una unidad. Por ejemplo para los segundos será 360/60 = 6 grados por cada segundo.

Añadimos nuestras tres agujas entre la HorasView y el Circle que hace de soporte. De este modo, el círculo será el último en dibujarse y quedará en la capa superior, encima de las aguas.

AgujaView(valor: 45, proporciónRadio: 0.9, ancho: 2, grados: 6)
    .foregroundColor(.red)          
AgujaView(valor: 30, proporciónRadio: 0.9, ancho: 5, grados: 6)         
AgujaView(valor: 1, proporciónRadio: 0.5, ancho: 8, grados: 30)

Para los segundos hemos añadido un color rojo para diferenciarla con el minutero ya en los relojes normalmente tienen la misma longitud.

Dando vida al reloj

El último, pero no menos importante de los pasos es conseguir animar nuestro reloj.

Y como estamos descubriendo los nuevos frameworks que Apple presentó en la WWDC de 2019, usaremos Combine. Éste nos permite controlar eventos asíncronos de una manera muy sencilla y se integra a las mil maravillas con SwiftUI. Si no estás familiarizado con su funcionamiento puedes revisar el ejemplo que publicamos aquí.

Lo primero, es lo primero: añadirlo a nuestro Playground.

import Combine

Seguidamente creamos un modelo que contenga los datos de la hora que necesitará nuestra vista. La nueva estructura recibirá la hora actual con el tipo Date y guardará las horas, minutos y segundos en variables.

struct HoraModelo {
    let horas, minutos, segundos: Int
    
    init(date: Date) {
        let calendar = Calendar.current
        let horas = calendar.component(.hour, from: date)
        horas = horas <= 12 ? horas : horas - 12
        minutos = calendar.component(.minute, from: date)
        segundos = calendar.component(.second, from: date)
    }
}

Después vamos a utilizar un temporizador gracias a el Publisher que proporciona a través de Combine la clase Timer del lenguaje Swift. Para albergar dicho temporizador con Combine, creamos un ObservableObject que informará a nuestra vista cada vez que se produzca una llamada al temporizador.

class MiTemporizador: ObservableObject {
    let temporizador = Timer.TimerPublisher(interval: 1.0, runLoop: .main, mode: .default)
    var cancelable: AnyCancellable?
    @Published var hora = Date()
    
    init() {
        self.cancelable = temporizador
            .autoconnect()
            .assign(to: \.hora, on: self)
    }
    
    deinit {
        self.cancelable?.cancel()
    }
}

La clase se inicia con el Publisher que asignamos a temporizador. Después conectamos la variable cancelable, que es un tipo de Subscriber, al Timer y le indicamos que asigne el resultado cada vez que se produzca una nueva llamada, a la propiedad hora.

Finalmente cancelamos las actualizaciones del suscriptor cuando se elimine la referencia a nuestra clase para limpiar todo.

El flujo de Combine permite crear un publicador Publisher que propaga valores hasta uno o más suscriptores Subscriber de forma asíncrona.

Ya tenemos una lógica que cada segundo despierta un Timer que publica la hora con el tipo Date y un modelo que convierte la hora en las unidades que necesitamos para nuestro reloj. Vamos a conectarlo a la vista y suscribir ésta a las actualizaciones.

struct ContenedorView: View {
    @State var unidades = HoraModelo(date: Date())
    @ObservedObject var reloj = MiTemporizador()
    
    var body: some View {
        ...
        }
        .onReceive(reloj.$hora) {
            self.unidades = HoraModelo(date: $0)
        }
    }
}

Añadimos la variable de estado unidades que contendrá los valores actualizados y que refrescará el contenido de body cuando se produzca un cambio. Después un ObservedObject de la clase que contenía el temporizador. Por último sólo resta añadir un bloque onReceive que modifique la variable de estado cada vez que se ejecuta el temporizador.

Ya hemos conectado la lógica del modelo con nuestra interfaz pero lo que buscamos es que se mueva las agujas. Entonces no tenemos más que usar la variable de estado para cambiar el valor de nuestras agujas.

AgujaView(valor: unidades.segundos, proporciónRadio: 0.9, ancho: 2, grados: 6)
                .foregroundColor(.red)
            AgujaView(valor: unidades.minutos, proporciónRadio: 0.9, ancho: 5, grados: 6)
            AgujaView(valor: unidades.horas, proporciónRadio: 0.5, ancho: 8, grados: 30)

Ya tenemos nuestro reloj funcionado como un reloj.

Resumen

Aunque pueda parecer un proceso largo, si tratamos de implementarlo utilizando la librería UIKit sería bastante más costoso, involucraría diseñar la interfaz de forma gráfica, constraints, bloques de animación… y bastante más lógica de fondo.

SwiftUI simplifica enormemente la tarea de crear interfaces a medida y sobretodo dibujar geometrías más complicadas, como coronas circulares. Y no podemos olvidarnos de Combine que reduce drásticamente la lógica a la hora de gestionar eventos asíncronos.

En nuestro reloj, hemos utilizado varios padding y cálculos con GeometryReader para posicionar correctamente los elementos y que se ajusten lo mejor posible a cualquier tamaño de pantalla. Quizás complique algo más el desarrollo pero es la manera de lograr el encaje perfecto.

Te dejamos el archivo con el Playground para que puedas visualizar el ejemplo completo y te invitamos a realizar tus pruebas y compartirlas con nosotros. Te recomendamos que abajo a la izquierda de la zona donde aparece el reloj, pinches en el icono y deshabilites «Enable Results» pues si no, puede que vaya un poco a trompicones.

SwiftUI tiene un gran potencial y además… ¡es muy divertido ir descubriéndolo! Un saludo y Good Apple Coding.

Arturo Rivas

Líder técnico especializado en mobile. Analista y desarrollador software. Apasionado de la tecnología, el deporte y la música.

Artículos relacionados

Botón volver arriba