
Cómo crear WORDLE app en SWIFTUI con la arquitectura MVVM
Aprende a crear el juego Wordle en SwiftUI. Te guío paso a paso para crear el modelo de datos, vistas y ViewModel en Xcode. En tan solo 30 minutos crearás un juego que fue vendido por más de 1 millón de euros. Arquitectura MVVM en SwiftUI
Tabla de contenido

Hoy en SwiftBeta vamos a aprender a cómo crear la app Wordle. Vamos a centrarnos en el juego, es decir, en cómo está construido y vamos a replicarlo. Vas a ver que en tan solo unos minutos vas a poder crear tu propio Wordle usando Xcode, SwiftUI y Swift.
Wordle en SwiftUI
Creamos el Modelo
Lo primero de todo que vamos hacer es crear dos vistas. La primera vista es la parte del juego (las celdas distribuidas en un Grid de 5x6), donde el usuario añade las palabras. Y la segunda parte es el teclado.
Para poder representar la información que se muestra en estas vistas, vamos a crear un fichero llamado Model.
Dentro de Model vamos a crear un enum llamado Status, este enum va a tener distintos estados para poder representar que:
- La letra que hemos añadido aparece en la palabra
- La letra que hemos añadido aparece y está en la posición correcta
- La letra que hemos añadido no aparece en la palabra
- Color de las celdas del juego y teclas del teclado
El código quedaría de la siguiente manera:
enum Status {
case normal
case match
case dontAppear
case appear
}
A continuación creamos una struct llamada LetterModel para añadir la letra que se mostrará en cada celda del grid y también en las teclas de nuestro teclado. Estas celdas y teclas tienen que saber que estado tienen, es por eso que vamos a crear una propiedad con el tipo Status que acabamos de crear, es decir:
struct LetterModel: Hashable {
let id: String = UUID().uuidString
let name: String
var status: Status
init(_ name: String) {
self.name = name
self.status = .normal
}
}
Ahora vamos a crear dos propiedades computadas que nos ayudaran a saber qué color poner en el texto y en el background de la celda y la tecla. Nuestra struct LetterModel quedaría de la siguiente manera:
struct LetterModel: Hashable {
let id: String = UUID().uuidString
let name: String
var status: Status
var backgroundColor: Color {
switch status {
case .normal:
return Color(red: 213.0/255, green: 216.0/255, blue: 222.0/255)
case .match:
return Color(red: 109.0/255, green: 169.0/255, blue: 103.0/255)
case .dontAppear:
return Color(red: 120.0/255, green: 124.0/255, blue: 127.0/255)
case .appear:
return Color(red: 201.0/255, green: 180.0/255, blue: 87.0/255)
}
}
var foregroundColor: Color {
switch status {
case .normal:
return .black
case .match, .dontAppear, .appear:
return .white
}
}
init(_ name: String) {
self.name = name
self.status = .normal
}
}
Ahora solo nos queda construir un Array de LetterModel que representará los datos necesarios para crear nuestro teclado.
var keyboardData: [LetterModel] = [
.init("Q"), .init("W"), .init("E"), .init("R"), .init("T"), .init("Y"), .init("U"), .init("I"), .init("O"), .init("P"),
.init("A"), .init("S"), .init("D"), .init("F"), .init("G"), .init("H"), .init("J"), .init("K"), .init("L"), .init("Ñ"),
.init("🚀"), .init(""), .init("Z"), .init("X"), .init("C"), .init("B"), .init("N"), .init("M"), .init(""), .init("🗑")
]
Esto es solo el modelo de datos, la información que necesitamos para representar de alguna manera nuestra vista. Es decir, este array que contiene la variable keyboardData lo podríamos representar visualmente de muchas maneras, pero nosotros usaremos un Grid para darle el aspecto de un teclado.
Creamos la vista KeyboardView
Lo siguiente que vamos hacer es usar un LazyVGrid en SwiftUI para crear el Grid que queremos y simular el aspecto de un teclado:
struct KeyboardView: View {
let columns: [GridItem] = Array(repeating: GridItem(.flexible(minimum: 20), spacing: 0), count: 10)
var body: some View {
LazyVGrid(columns: columns,
spacing: 12) {
ForEach(keyboardData, id: \.id) { model in
Button(action: {
// TODO: Send letter ViewModel
}, label: {
Text(model.name)
.font(.body)
})
.frame(width: 34, height: 50)
.foregroundColor(model.foregroundColor)
.background(model.backgroundColor)
.cornerRadius(8)
}
}
.padding(.horizontal, 8)
}
}
Podemos ver en el Canvas cómo queda nuestro teclado. Hemos creado 10 columnas con un GridItem flexible de mínimo 20.
Ahora ya tenemos la vista inferior. Más tarde volveremos a ella para conectar con nuestro ViewModel (el que crearemos más adelante).
Creamos la vista GameView
Ahora vamos a centrarnos en la vista superior del juego, en la que el user añade las letras en un Grid. Para hacerlo vamos a seguir el mismo proceso que antes, vamos a crear una variable llamada gameData, y esta variable va a representar las filas de nuestro Grid (de nuestro tablero).
var gameData: [[LetterModel]] =
[
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")]
]
A continuación vamos a crear un LazyVGrid, tal y como hemos creado para nuestro teclado:
struct GameView: View {
private let columns: [GridItem] = Array(repeating: GridItem(.flexible(minimum: 20), spacing: 0), count: 5)
var body: some View {
LazyVGrid(columns: columns,
spacing: 8) {
ForEach(0...5, id: \.self) { index in
ForEach(gameData[index], id: \.id) { model in
Button(action: {
// TODO
}, label: {
Text(model.name)
.font(.system(size: 40, weight: .bold))
})
.frame(width: 60, height: 60)
.foregroundColor(model.foregroundColor)
.background(model.backgroundColor)
}
}
}
.padding(.horizontal, 20)
}
}
Dentro del LazyVGrid creamos un ForEach para saber en qué columna estamos y así poner la posición correcta los datos de la variable gameData.
Una vez hemos creado el Modelo y las vistas, ahora vamos a unir la vista GameView y KeyboardView en nuestro ContentView.
Unir GameView y KeyboardView en ContentView
Para mostrar las dos vistas en ContentView, vamos a utilizar un VStack, tan sencillo como hacer lo siguiente:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(spacing: 40) {
GameView()
KeyboardView()
}
}
}
Una vez hemos creado nuestras vistas, lo siguiente que vamos a crear es el ViewModel. El responsable de crear toda la lógica de nuestro juego.
Creamos el ViewModel
Creamos nuestro ViewModel y añadimos las siguiente propiedades:
final class ViewModel: ObservableObject {
var numOfRow: Int = 0
@Published var word: [LetterModel] = []
@Published var gameData: [[LetterModel]] =
[
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")],
[.init(""), .init(""), .init(""), .init(""), .init("")]
]
}
- numOfRow nos servirá para saber en qué fila estamos de nuestro Grid
- word nos servirá para hacer comprobaciones a la palabra que está insertando un user
- gameData, representación de nuestro Grid, de nuestro tablero de juego. Al inicializar el juego este tablero está vacío, pero a medida que un user añada palabras se irá rellenando.
Fíjate que ya teníamos una variable llamada gameData que habíamos creado en la vista de GameView. PERO ya no la necesitamos, es decir la puedes borrar, ya que el ViewModel será el responsable de tener esta información (y de modificarla!).
Lo siguiente que vamos hacer es añadir un método llamado addNewLetter y aceptará un parámetro que será de tipo LetterModel:
func addNewLetter(letterModel: LetterModel) {
if letterModel.name == "🚀" {
tapOnSend()
return
}
if letterModel.name == "🗑" {
tapOnRemove()
return
}
if word.count < 5 {
let letter = LetterModel(letterModel.name)
word.append(letter)
game[numOfRow][word.count-1] = letter
}
}
Este método como bien has deducido lo vamos a conectar con nuestro KeyboardView:
- Si el button es un cohete llamará a un método que aplicará cierta lógica
- Si llama a la cesta de la basura llamará a otra lógica completamente diferente
- Si no es ninguna de las teclas anteriores, lo que hará será almacenar la nueva letra en la casilla correcta de nuestro Grid.
Para que el compilador no se queje, vamos a crear los dos métodos:
- tapOnSend()
- tapOnRemove()
private func tapOnSend() {
print("Tap on send")
}
private func tapOnRemove() {
print("Tap on remove")
}
Y ahora vamos a conectar este método addNewLetter con nuestra vista KeyboardView cada vez que se pulse una tecla, lo único que tenemos que hacer es crear una instancia de ViewModel y llamar al método addNewLetter cada vez que se pulse una tecla:
struct KeyboardView: View {
@ObservedObject var viewModel: ViewModel
let columns: [GridItem] = Array(repeating: GridItem(.flexible(minimum: 20), spacing: 0), count: 10)
var body: some View {
LazyVGrid(columns: columns,
spacing: 12) {
ForEach(keyboardData, id: \.id) { model in
Button(action: {
viewModel.addNewLetter(letterModel: model)
}, label: {
Text(model.name)
.font(.body)
})
.frame(width: 34, height: 50)
.foregroundColor(model.foregroundColor)
.background(model.backgroundColor)
.cornerRadius(8)
}
}
.padding(.horizontal, 8)
}
}
Arreglamos también la preview de nuestra vista:
struct KeyboardView_Previews: PreviewProvider {
static var previews: some View {
KeyboardView(viewModel: ViewModel())
}
}
Ahora, si intentamos compilar nuestra app fallará, ya que KeyboardView espera una instancia de ViewModel. Vamos a arreglarlo creando e instanciando una propiedad de ViewModel en ContentView:
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 40) {
GameView()
KeyboardView(viewModel: viewModel)
}
}
}
Vamos a compilar nuestra app y vamos a ver qué hemos conseguido. De momento tenemos la vista bien estructurada, y cuando pulsamos una tecla de nuestro teclado se printa su valor por consola, diferenciando 3 casos:
- Se ha pulsado la tecla de 🚀
- Se ha pulsado la tecla de 🗑
- Se ha pulsado cualquier otra tecla.
Ahora vamos a continuar añadiendo código en nuestro ViewModel. Lo siguiente que vamos hacer es crear una propiedad con la palabra que estamos buscando, en nuestro caso vamos a asignarle el valor de "REINA".
@Published var result: String = "REINA"
y también vamos a añadir un método que nos va a indicar si una palabra existe o no.
private func wordIsReal(word: String) -> Bool {
UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm: word)
}
Si intentamos compilar ahora fallará, debemos importar en nuestro ViewModel el framework UIKit
Lo siguiente que vamos hacer es rellenar el método de tapOnSend(), dentro de este método vamos a aplicar la lógica más importante de nuestra app.
private func tapOnSend() {
guard word.count == 5 else {
print("¡Añade más letras!")
return
}
let finalStringWord = word.map { $0.name }.joined()
if wordIsReal(word: finalStringWord) {
print("Correct word")
for (index, _) in word.enumerated() {
let currentCharacter = word[index].name
var status: Status
if result.contains(where: { String($0) == currentCharacter }) {
status = .appear
print("\(currentCharacter) Appear")
if currentCharacter == String(result[result.index(result.startIndex, offsetBy: index)]) {
status = .match
print("\(word[index].name) MATCH ✅")
}
} else {
status = .dontAppear
print("\(word[index].name) DONT Appear")
}
// Update GameView
var updateGameBoardCell = game[numOfRow][index]
updateGameBoardCell.status = status
game[numOfRow][index] = updateGameBoardCell
// Update KeyboardView
let indexToUpdate = keyboardData.firstIndex(where: { $0.name == word[index].name })
var keyboardKey = keyboardData[indexToUpdate!]
if keyboardKey.status != .match {
keyboardKey.status = status
keyboardData[indexToUpdate!] = keyboardKey
}
}
// Clean word and move to the next row
word = []
numOfRow += 1
} else {
print("Incorrect word")
}
}
Para poder ver los cambios en nuestra vista GameView debemos crear una propiedad de tipo ViewModel. Tal y como hemos hecho con KeyboardView.
import SwiftUI
struct GameView: View {
@ObservedObject var viewModel: ViewModel
private let columns: [GridItem] = Array(repeating: GridItem(.flexible(minimum: 20), spacing: 0), count: 5)
var body: some View {
LazyVGrid(columns: columns,
spacing: 8) {
ForEach(0...columns.count, id: \.self) { index in
ForEach(viewModel.game[index], id: \.id) { model in
Button(action: {
// TODO
}, label: {
Text(model.name)
.font(.system(size: 40, weight: .bold))
})
.frame(width: 60, height: 60)
.foregroundColor(model.foregroundColor)
.background(model.backgroundColor)
}
}
}
.padding(.horizontal, 20)
}
}
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView(viewModel: ViewModel())
}
}
También, en nuestra vista ContentView debemos pasarle la instancia que hemos creado de ViewModel y pasarsela a GameView.
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 40) {
GameView(viewModel: viewModel)
KeyboardView(viewModel: viewModel)
}
}
}
Vamos a compilar, y vamos a probar nuestra app.
¡Perfecto! vemos como al pulsar una tecla se modifica nuestro Grid, nuestro tablero. Ahora vamos a rellenar un método para poder eliminar letras de una palabra. Para ello, volvemos a nuestro ViewModel y rellenamos el método tapOnRemove()
private func tapOnRemove() {
guard word.count > 0 else {
return
}
game[numOfRow][word.count-1] = .init("")
word.removeLast()
}
Creamos la pantalla de error o victoria
En nuestro anterior código printabamos un error por consola, lo que haremos a continuación es mostrar un banner, es decir, una vista indicando qué error ha ocurrido y también marcaremos en rojo las celdas del Grid, para que sea más fácil de visualizar dónde tenemos el error.
También vamos a aprovechar y vamos a añadir el caso de que un user gane la partida.
Para hacerlo, nos vamos a nuestro ViewModel y creamos el siguiente enum:
enum BannerType {
case error(String)
case success
}
Y dentro de la clase ViewModel vamos a crear una propiedad nueva de tipo BannerType
@Published var bannerType: BannerType? = nil
Ahora, cada vez que tengamos un error, vamos a asigna a esta propiedad el case error con un mensaje. Es decir, el método tapOnSend() nos quedaría de la siguiente manera:
private func tapOnSend() {
guard word.count == 5 else {
print("¡Añade más letras!")
bannerType = .error("¡Añade más letras!")
return
}
let finalStringWord = word.map { $0.name }.joined()
if wordIsReal(word: finalStringWord) {
print("Correct word")
for (index, _) in word.enumerated() {
let currentCharacter = word[index].name
var status: Status
if result.contains(where: { String($0) == currentCharacter }) {
status = .appear
print("\(currentCharacter) Appear")
if currentCharacter == String(result[result.index(result.startIndex, offsetBy: index)]) {
status = .match
print("\(currentCharacter) MATCH ✅")
}
} else {
status = .dontAppear
print("\(currentCharacter) DONT Appear")
}
// Update GameView
var updateGameBoardCell = game[numOfRow][index]
updateGameBoardCell.status = status
game[numOfRow][index] = updateGameBoardCell
// Update KeyboardView
let indexToUpdate = keyboardData.firstIndex(where: { $0.name == word[index].name })
var keyboardKey = keyboardData[indexToUpdate!]
if keyboardKey.status != .match {
keyboardKey.status = status
keyboardData[indexToUpdate!] = keyboardKey
}
}
// Clean word and move to the next row
word = []
numOfRow += 1
} else {
print("Incorrect word")
bannerType = .error("¡Palabra incorrecta, no existe!")
}
}
Ahora solo nos falta crear un método nuevo para saber cuando tenemos un error, y este nuevo método lo llamaremos desde la vista GameView.
func hasError(index: Int) -> Bool {
guard let bannerType = bannerType else {
return false
}
switch bannerType {
case .error(_):
return index == numOfRow
case .success:
return false
}
}
Antes de irnos a la vista, vamos a añadir la siguiente línea del código justo al principio del método addNewLetter:
bannerType = nil
de esta manera si se pulsa una tecla nueva desmarcamos todas las celdas del Grid que estaban en rojo.
Y ahora si, vamos a llamar al método hasError desde GameView, así cuando obtengamos un error cambiaremos el color del texto y del background en el Grid, es decir:
import SwiftUI
struct GameView: View {
@ObservedObject var viewModel: ViewModel
private let columns: [GridItem] = Array(repeating: GridItem(.flexible(minimum: 20), spacing: 0), count: 5)
var body: some View {
LazyVGrid(columns: columns,
spacing: 8) {
ForEach(0...columns.count, id: \.self) { index in
ForEach(viewModel.game[index], id: \.id) { model in
Button(action: {
// TODO
}, label: {
Text(model.name)
.font(.system(size: 40, weight: .bold))
})
.frame(width: 60, height: 60)
.foregroundColor(viewModel.hasError(index: index) ? .white : model.foregroundColor)
.background(viewModel.hasError(index: index) ? .red : model.backgroundColor)
}
}
}
.padding(.horizontal, 20)
}
}
Fíjate que hemos añadido una condición en el modificador .foregroundColor y .background.
Ahora lo único que nos falta es mostrar una vista para indicar a un user que error está obteniendo. Primero debemos crear la vista:
import Foundation
import SwiftUI
struct BannerView: View {
private let bannerType: BannerType
@State private var isOnScreen: Bool = false
init(bannerType: BannerType) {
self.bannerType = bannerType
}
var body: some View {
VStack {
Spacer()
switch bannerType {
case .error(let errorMessage):
Text(errorMessage)
.foregroundColor(.white)
.padding()
.background(.red)
.cornerRadius(12)
case .success:
Text("¡HAS GANADO!")
.foregroundColor(.white)
.padding()
.background(.blue)
.cornerRadius(12)
}
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 40)
.animation(.easeInOut(duration: 0.3), value: isOnScreen)
.offset(y: isOnScreen ? -350 : -500)
.onAppear {
isOnScreen = true
}
}
}
Y ahora vamos a llamarla desde ContentView y para hacerlo vamos a añadir un ZStack:
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
ZStack {
VStack(spacing: 40) {
GameView(viewModel: viewModel)
KeyboardView(viewModel: viewModel)
}
if viewModel.bannerType != nil {
BannerView(bannerType: viewModel.bannerType!)
}
}
}
}
¡Perfecto! Ahora cada vez que tenemos un error estamos marcando la fila del Grid en rojo y también estamos mostrando un banner con el error específico. Ahora vamos a hacer un cambio muy simple para indicar que un user ha ganado.
Dentro de nuestro ViewModel, justo después de actualizar el teclado, añadimos las siguientes líneas que nos van a indicar si un user ha ganado o no, nuestro métodod tapOnSend debería quedar de la siguiente manera:
private func tapOnSend() {
guard word.count == 5 else {
print("¡Añade más letras!")
bannerType = .error("¡Añade más letras!")
return
}
let finalStringWord = word.map { $0.name }.joined()
if wordIsReal(word: finalStringWord) {
print("Correct word")
for (index, _) in word.enumerated() {
let currentCharacter = word[index].name
var status: Status
if result.contains(where: { String($0) == currentCharacter }) {
status = .appear
print("\(currentCharacter) Appear")
if currentCharacter == String(result[result.index(result.startIndex, offsetBy: index)]) {
status = .match
print("\(currentCharacter) MATCH ✅")
}
} else {
status = .dontAppear
print("\(currentCharacter) DONT Appear")
}
// Update GameView
var updateGameBoardCell = game[numOfRow][index]
updateGameBoardCell.status = status
game[numOfRow][index] = updateGameBoardCell
// Update KeyboardView
let indexToUpdate = keyboardData.firstIndex(where: { $0.name == word[index].name })
var keyboardKey = keyboardData[indexToUpdate!]
if keyboardKey.status != .match {
keyboardKey.status = status
keyboardData[indexToUpdate!] = keyboardKey
}
}
let isUserWinner = game[numOfRow].reduce(0) { partialResult, letterModel in
if letterModel.status == .match {
return partialResult + 1
}
return 0
}
if isUserWinner == 5 {
bannerType = .success
} else {
// Clean word and move to the next row
word = []
numOfRow += 1
}
} else {
print("Incorrect word")
bannerType = .error("¡Palabra incorrecta, no existe!")
}
}
Si ahora compilamos nuestra app y la probamos, vemos como si acertamos la palabra, poniendo REINA, vemos como aparece el banner en la parte superior indicando que hemos ganado.
Conclusión
Hoy hemos aprendido a cómo crear el juego Wordle (que tanto está de moda) en SwiftUI. Hemos ido paso por paso, creando el modelo, las vistas y luego el ViewModel. En tan solo 30 minutos hemos podido replicar este juego.