To debounce a local Swift calculation, cancel the previous delayed task whenever input changes, then calculate after a short pause. This macOS tutorial builds a synthetic square-tile counter that updates only after the latest valid input settles.
Define exactly what the calculator should do
The reader task is straightforward: add a delayed preview to a Mac app’s number field. Imagine a graphic made from square layers: the first contains one tile, the second contains four, and the third contains nine. Entering a layer count calculates the total tiles across those layers. Everything stays in memory and uses synthetic numbers.
For this example, the input contract is:
- Accept a base-10 integer from
0through1000, inclusive. - Remove leading and trailing whitespace before parsing.
- Show an instruction immediately for empty input or an error for invalid input.
- For valid input, clear the previous answer and wait
300milliseconds before calculating. - Cancel the pending calculation whenever another edit arrives or the view disappears.
The result is the sum of the squares from 1 through the requested layer count. The formula is n * (n + 1) * (2 * n + 1) / 6. For 3 layers, the illustrative calculation is 1 + 4 + 9 = 14. For 0, the result is 0. These are mathematical examples, not measured application results.
The 300-millisecond interval is an example interaction choice. It creates a trailing debounce: each new edit restarts the quiet period. Continuous editing can postpone the calculation indefinitely. Choose this behavior when a settled-input preview is useful; a command that must respond to every individual edit needs a different contract.
Why cancellation must stop the delayed path
A task handle lets the app cancel previously scheduled work. Swift tasks can begin running as soon as they are created, and dropping a handle does not stop its task. Store the handle so the next edit can cancel it. Apple’s Task documentation describes both behaviors.
The delay uses try await Task.sleep(for: .milliseconds(300)). Apple documents that this suspends the task without blocking its underlying thread and throws CancellationError when canceled before the delay ends. The duration overload is available on macOS 13 and later. Task.sleep documentation
Cancellation remains cooperative: calling cancel() does not forcibly interrupt arbitrary calculation code. The delayed path must return when sleep throws, and it should check cancellation again before calculating. Task.cancel documentation, Task.checkCancellation documentation
A common mistake is using try? for sleep and then continuing directly into the calculation. That discards the thrown cancellation error and permits the following statements to run. Here, catching the error exits the task before it can produce an outdated answer.
Build the complete macOS example
Create a macOS app project in Xcode using the SwiftUI interface and Swift language. Use a Swift 6 toolchain and set the deployment target to macOS 14 or later. This example uses the modern zero-argument onChange(of:initial:_:) overload, which Apple lists as available from macOS 14. SwiftUI onChange documentation
Replace the generated app entry-point file with the complete program below. Remove the generated ContentView.swift file from the target, or replace its contents so that there is only one ContentView declaration. Keep exactly one app declaration marked @main in the target.
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()
}
}
}
The view owns its calculator with @StateObject. Apple documents that this preserves the model instance across changes to the container’s inputs, while a change in view identity creates a new instance. Published model properties drive the displayed status and answer. StateObject documentation
The model and task closure are explicitly marked @MainActor. Apple defines this actor’s executor as equivalent to the main dispatch queue. In this implementation, scheduling, cancellation, and result assignment therefore share the same actor. The sleep suspends; the short arithmetic expression runs on that actor after the wait. MainActor documentation
How the input and result rules prevent stale previews
Cancellation happens before validation. If the user changes a valid number to an empty field or an invalid value, the older pending calculation is still canceled. Returning early from validation cannot leave that older delay running toward a visible result.
The program clears result on every edit. This is a deliberate display rule: the answer disappears while a new value waits, so the interface never presents an old total as the answer to the current text. An alternative design could retain the answer with a clear previous-result label, but that would require a different display contract.
Foundation’s trimmingCharacters(in:) removes matching characters from both ends of a string. Using .whitespacesAndNewlines means input such as 3 becomes 3; internal characters remain untouched. Foundation trimming documentation
Swift’s integer parser defaults to base 10, accepts an optional leading sign, and returns nil for invalid or unrepresentable values. Consequently, +3 and 003 are accepted here, while 3.5, 1,000, and 3 0 are rejected. A parsed negative value is rejected by the separate range check. Int.init documentation
The upper bound also constrains arithmetic. At 1000 layers, the numerator is 2,003,001,000 and the total is 333,833,500. Every permitted input stays within those nonnegative intermediate bounds. Raising the limit requires checking intermediate multiplication as well as the final result; dividing by six afterward cannot repair an overflow that already occurred.
Each delayed task captures the validated layers value for that edit. It does not reread the field after sleeping. Once the task passes its cancellation check, there is no further await before it assigns the result. Another main-actor scheduling call cannot interleave with that synchronous calculation-and-assignment segment. Swift’s language guide explains actor isolation and explicitly marked suspension points. Concurrency — The Swift Programming Language
This reasoning depends on keeping the calculation synchronous and tiny. If you later insert an asynchronous operation, recheck cancellation and whether the request is still current after that operation returns. A revision identifier can distinguish successive requests, including cases where the user returns to an earlier input value.
Check the behavior with synthetic inputs
Build and run the app, then use this acceptance sequence. Expected results follow from the program’s contract; exact wake-up times depend on scheduling.
| Action | Expected behavior |
|---|---|
Open the window with initial input 3 |
Show the waiting status, then Total tiles: 14. |
Replace the input with 4 |
Clear the previous total, then show Total tiles: 30. |
Enter 1, then 10 before the first delay completes |
Restart the delay; the settled value produces Total tiles: 385. |
Enter 4, then erase it before the delay completes |
Show Enter a layer count. and no total. |
Enter 3.5 or 1001 |
Show the range instruction immediately and no total. |
Enter 0 |
After the delay, show Total tiles: 0. |
Enter 1000 |
After the delay, show Total tiles: 333833500. |
For cancellation checks, make the next edit comfortably before the delay expires. At the boundary, an older result may already have completed before the next edit is processed. The next edit still clears it and starts the new request. A delay is not an exact execution appointment; Swift’s language guide describes sleep as suspending for at least the requested interval. Swift concurrency documentation
The arithmetic examples above were checked independently with integer calculations. A Swift compiler was unavailable in the research environment, so the macOS program was inspected but not compiled or executed there.
Keep cancellation tied to the view’s lifetime
The disappearance handler calls stop() explicitly. Apple documents that onDisappear runs after the view disappears, with its exact timing depending on the view type. Treat it as the view’s cleanup hook rather than an exact window-event deadline. The appearance handler schedules the current input again whenever the view appears. SwiftUI onDisappear documentation
The cancellation catch deliberately returns without changing shared status or clearing the stored handle. An older canceled task might resume after a newer task has been stored; letting that older task perform unconditional cleanup could erase the newer handle. In this program, the scheduling and stopping methods clear canceled handles, and only a successfully completing task clears its own active handle.
Debouncing controls when work starts. It does not make a long synchronous calculation responsive, nor can cancellation undo a result already assigned. For this bounded formula, the useful pattern is compact: cancel the previous handle, validate a captured input, suspend for the quiet period, exit on cancellation, and commit the short calculation on the main actor. That gives the Mac interface a predictable settled-input preview.
Sources
- Task — Apple.
- sleep(for:tolerance:clock:) — Apple.
- cancel() — Apple.
- checkCancellation() — Apple.
- onChange(of:initial:_:) — Apple.
- StateObject — Apple.
- MainActor — Apple.
- trimmingCharacters(in:) — Apple.
- init(_:radix:) — Apple.
- Concurrency — The Swift Programming Language — Swift.org.
- onDisappear(perform:) — Apple.