Para aplicar debounce a un cálculo local de Swift, cancela la tarea diferida anterior cada vez que cambie la entrada y calcula después de una breve pausa. Este tutorial para macOS crea un contador sintético de fichas cuadradas que solo se actualiza cuando se estabiliza la última entrada válida.

Define exactamente qué debe hacer la calculadora

La tarea para quien lee es sencilla: añadir una vista previa diferida a un campo numérico de una app para Mac. Imagina un gráfico formado por capas cuadradas: la primera contiene una ficha, la segunda contiene cuatro y la tercera, nueve. Al introducir un número de capas, se calcula el total de fichas de todas ellas. Todo permanece en memoria y usa números sintéticos.

Para este ejemplo, el contrato de entrada es el siguiente:

  • Aceptar un entero en base diez desde 0 hasta 1000, ambos inclusive.
  • Eliminar los espacios en blanco iniciales y finales antes de analizar la entrada.
  • Mostrar una instrucción de inmediato si la entrada está vacía o un error si no es válida.
  • Si la entrada es válida, borrar el resultado anterior y esperar 300 milisegundos antes de calcular.
  • Cancelar el cálculo pendiente cada vez que llegue otra edición o desaparezca la vista.

El resultado es la suma de los cuadrados desde 1 hasta el número de capas solicitado. La fórmula es n * (n + 1) * (2 * n + 1) / 6. Para 3 capas, el cálculo ilustrativo es 1 + 4 + 9 = 14. Para 0, el resultado es 0. Son ejemplos matemáticos, no resultados medidos de la aplicación.

El intervalo de 300 milisegundos es una opción de interacción a modo de ejemplo. Crea un debounce al final del periodo: cada nueva edición reinicia el periodo de espera. La edición continua puede posponer el cálculo indefinidamente. Elige este comportamiento cuando sea útil mostrar una vista previa de la entrada estabilizada; un comando que deba responder a cada edición necesita un contrato distinto.

Por qué la cancelación debe detener la ruta diferida

Un identificador de tarea permite cancelar el trabajo programado anteriormente. Las tareas de Swift pueden empezar a ejecutarse en cuanto se crean, y descartar un identificador no detiene la tarea. Guarda el identificador para que la siguiente edición pueda cancelarla. La documentación de Task de Apple describe ambos comportamientos.

La espera usa try await Task.sleep(for: .milliseconds(300)). Apple documenta que esto suspende la tarea sin bloquear el hilo subyacente y lanza CancellationError si se cancela antes de que termine la espera. La sobrecarga que acepta una duración está disponible en macOS 13 y versiones posteriores. Documentación de Task.sleep

La cancelación sigue siendo cooperativa: llamar a cancel() no interrumpe por la fuerza código de cálculo arbitrario. La ruta diferida debe regresar si la espera lanza un error y debe volver a comprobar la cancelación antes de calcular. Documentación de Task.cancel, documentación de Task.checkCancellation

Un error común es usar try? para la espera y continuar directamente con el cálculo. Esto descarta el error de cancelación lanzado y permite que se ejecuten las instrucciones siguientes. En este caso, capturar el error hace que la tarea termine antes de producir un resultado obsoleto.

Crear el ejemplo completo para macOS

Crea un proyecto de app para macOS en Xcode con la interfaz SwiftUI y el lenguaje Swift. Usa la cadena de herramientas de Swift 6 y establece macOS 14 o posterior como destino de implementación. Este ejemplo usa la sobrecarga moderna onChange(of:initial:_:) sin argumentos, que Apple indica que está disponible desde macOS 14. Documentación de onChange de SwiftUI

Reemplaza el archivo generado que contiene el punto de entrada de la app por el programa completo que aparece a continuación. Elimina el archivo generado ContentView.swift del destino o reemplaza su contenido para que solo haya una declaración de ContentView. Mantén exactamente una declaración de app marcada con @main en el destino.

import SwiftUI
import Combine
import Foundation

@MainActor
final class TileCalculator: ObservableObject {
    @Published private(set) var result: Int?
    @Published private(set) var status = "Enter a layer count."

    private var pending: Task<Void, Never>?

    func schedule(_ text: String) {
        pending?.cancel()
        pending = nil
        result = nil

        let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !cleaned.isEmpty else {
            status = "Enter a layer count."
            return
        }
        guard let layers = Int(cleaned, radix: 10),
              (0...1000).contains(layers) else {
            status = "Use a whole number from 0 to 1000."
            return
        }

        status = "Waiting for typing to pause..."
        pending = Task { @MainActor [weak self] in
            do {
                try await Task.sleep(for: .milliseconds(300))
                try Task.checkCancellation()
            } catch {
                return
            }

            guard let self else { return }
            let total = layers * (layers + 1) * (2 * layers + 1) / 6
            self.result = total
            self.status = "Calculated for \(layers) layers."
            self.pending = nil
        }
    }

    func stop() {
        pending?.cancel()
        pending = nil
        result = nil
        status = "Calculation paused."
    }
}

@MainActor
struct ContentView: View {
    @State private var input = "3"
    @StateObject private var calculator = TileCalculator()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Square-layer tile counter")
                .font(.headline)
            TextField("Layers: 0 to 1000", text: $input)
                .textFieldStyle(.roundedBorder)
            Text(calculator.status)
            if let total = calculator.result {
                Text("Total tiles: \(total)")
                    .font(.title2)
            }
        }
        .padding(24)
        .frame(minWidth: 360, minHeight: 180)
        .onAppear {
            calculator.schedule(input)
        }
        .onChange(of: input) {
            calculator.schedule(input)
        }
        .onDisappear {
            calculator.stop()
        }
    }
}

@main
@MainActor
struct LocalTileCalculatorApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

La vista administra su calculadora con @StateObject. Apple documenta que esto conserva la instancia del modelo cuando cambian las entradas del contenedor, mientras que un cambio en la identidad de la vista crea una instancia nueva. Las propiedades publicadas del modelo controlan el estado y el resultado que se muestran. Documentación de StateObject

El modelo y el cierre de la tarea están marcados explícitamente con @MainActor. Apple define que el ejecutor de este actor es equivalente a la cola principal de despacho. En esta implementación, la planificación, la cancelación y la asignación del resultado comparten ese actor. La espera suspende la tarea; después, la breve expresión aritmética se ejecuta en ese actor. Documentación de MainActor

Cómo las reglas de entrada y resultado evitan vistas previas obsoletas

La cancelación ocurre antes de la validación. Si el usuario cambia un número válido por un campo vacío o un valor no válido, el cálculo pendiente anterior se cancela igualmente. Regresar antes tras la validación no deja esa espera anterior en curso hasta producir un resultado visible.

El programa borra result en cada edición. Es una regla de visualización deliberada: el resultado desaparece mientras espera el nuevo valor, así la interfaz nunca presenta un total anterior como respuesta al texto actual. Un diseño alternativo podría conservar el resultado con una etiqueta que lo identificara claramente como anterior, pero eso requeriría un contrato de visualización distinto.

trimmingCharacters(in:) de Foundation elimina los caracteres coincidentes de ambos extremos de una cadena. Usar .whitespacesAndNewlines significa que una entrada como 3 se convierte en 3; los caracteres internos no se modifican. Documentación de Foundation sobre el recorte de cadenas

El analizador de enteros de Swift usa la base 10 de forma predeterminada, acepta un signo inicial opcional y devuelve nil para los valores no válidos o no representables. Por lo tanto, aquí se aceptan +3 y 003, mientras que se rechazan 3.5, 1,000 y 3 0. Un valor negativo analizado se rechaza mediante la comprobación de rango independiente. Documentación de Int.init

El límite superior también restringe las operaciones aritméticas. Con 1000 capas, el numerador es 2,003,001,000 y el total es 333,833,500. Todas las entradas permitidas se mantienen dentro de esos límites intermedios no negativos. Para elevar el límite, hay que comprobar la multiplicación intermedia además del resultado final; dividir entre seis después no puede corregir un desbordamiento que ya se haya producido.

Cada tarea diferida captura el valor validado de layers para esa edición. No vuelve a leer el campo después de la espera. Una vez que la tarea supera la comprobación de cancelación, no hay más await antes de asignar el resultado. Otra llamada de planificación en el actor principal no puede intercalarse con ese segmento síncrono de cálculo y asignación. La guía del lenguaje Swift explica el aislamiento de actores y los puntos de suspensión marcados explícitamente. Concurrencia — El lenguaje de programación Swift

Este razonamiento depende de que el cálculo siga siendo síncrono y breve. Si más adelante añades una operación asíncrona, vuelve a comprobar la cancelación y si la solicitud sigue vigente cuando esa operación termine. Un identificador de revisión puede distinguir solicitudes sucesivas, incluso si el usuario vuelve a un valor de entrada anterior.

Comprobar el comportamiento con entradas sintéticas

Compila y ejecuta la app, y luego sigue esta secuencia de aceptación. Los resultados esperados se desprenden del contrato del programa; los tiempos exactos de reanudación dependen de la planificación.

Acción Comportamiento esperado
Abrir la ventana con la entrada inicial 3 Mostrar el estado de espera y luego Total de fichas: 14.
Reemplazar la entrada por 4 Borrar el total anterior y luego mostrar Total de fichas: 30.
Introducir 1 y después 10 antes de que termine la primera espera Reiniciar la espera; el valor estabilizado produce Total de fichas: 385.
Introducir 4 y borrarlo antes de que termine la espera Mostrar Introduce un número de capas. y ningún total.
Introducir 3.5 o 1001 Mostrar de inmediato la instrucción sobre el rango y ningún total.
Introducir 0 Después de la espera, mostrar Total de fichas: 0.
Introducir 1000 Después de la espera, mostrar Total de fichas: 333833500.

Para comprobar la cancelación, haz la siguiente edición bastante antes de que venza la espera. En el límite, es posible que el resultado anterior ya se haya completado antes de que se procese la siguiente edición. La siguiente edición lo borra igualmente e inicia la nueva solicitud. Una espera no garantiza una hora exacta de ejecución; la guía del lenguaje Swift explica que la suspensión dura como mínimo el intervalo solicitado. Documentación de concurrencia de Swift

Los ejemplos aritméticos anteriores se comprobaron de forma independiente con cálculos de enteros. No había un compilador de Swift disponible en el entorno de investigación, así que el programa para macOS se inspeccionó, pero no se compiló ni ejecutó allí.

Vincular la cancelación al ciclo de vida de la vista

El manejador de desaparición llama explícitamente a stop(). Apple documenta que onDisappear se ejecuta después de que desaparece la vista; el momento exacto depende del tipo de vista. Considéralo un punto de limpieza de la vista, no un plazo exacto para un evento de ventana. El manejador de aparición vuelve a programar la entrada actual cada vez que aparece la vista. Documentación de onDisappear de SwiftUI

El bloque de captura de cancelación regresa deliberadamente sin cambiar el estado compartido ni borrar el identificador almacenado. Una tarea anterior cancelada podría reanudarse después de que se haya guardado una tarea nueva; si esa tarea anterior hiciera una limpieza incondicional, podría borrar el identificador nuevo. En este programa, los métodos de planificación y detención borran los identificadores cancelados, y solo una tarea que termina correctamente borra su propio identificador activo.

El debounce controla cuándo empieza el trabajo. No hace que un cálculo síncrono largo responda con rapidez, ni puede deshacer un resultado ya asignado. Para esta fórmula acotada, el patrón útil es sencillo: cancelar el identificador anterior, validar una entrada capturada, suspender la tarea durante el periodo de espera, salir si se cancela y confirmar el cálculo breve en el actor principal. Así, la interfaz de Mac muestra una vista previa predecible de la entrada estabilizada.

Fuentes