← All daysDay 27 of 49 · Part 7: Input and interaction
Day 27

Drag and drop, and haptics

These are the lesson notes for this day. Its build-along tutorial and flash cards have not been written yet.

Lesson 31

Drag and drop, and haptics

Drag and drop is built on the Transferable protocol, which describes how a type is converted to and from data. Make a view draggable with .draggable(item), and accept drops with .dropDestination(for:). The same protocol powers copy and paste and the share sheet (ShareLink). Haptics are declarative: .sensoryFeedback(.success, trigger: value) plays feedback each time value changes.

Coming from UIKit, Transferable replaces the plumbing around NSItemProvider, and .sensoryFeedback replaces UIFeedbackGenerator.

Exercise

Let the user reorder trips by dragging, drag a trip onto a "Favorites" area, and feel a haptic when the drop succeeds.

After this lesson you should be able to answer
  1. What does conforming to Transferable describe about a type?
  2. Which modifier makes a view draggable, and which makes a view accept drops?
  3. How do you trigger a haptic in SwiftUI?
Resources
How would you test it
Interview problem · Part 7

Testing a debounced search

“This waits 300 ms after the last keystroke before searching. How would you unit test it? I do not want a test suite that sleeps.”

@MainActor @Observable
final class SearchModel {
    var query = "" { didSet { scheduleSearch() } }
    private(set) var results: [String] = []
    private var task: Task<Void, Never>?

    private func scheduleSearch() {
        task?.cancel()
        task = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            results = await API.shared.search(query)
        }
    }
}

Where this comes from: swift-clocks: TestClock (package) · Testing asynchronous code — the standard senior question about time-dependent code: “how do you test this without waiting?”

Show the answer

As written it cannot be tested well. It hides two dependencies: time (Task.sleep uses the real clock) and the network (API.shared). Name them, then turn each into something the test can supply:

@MainActor @Observable
final class SearchModel {
    var query = "" { didSet { scheduleSearch() } }
    private(set) var results: [String] = []
    private var task: Task<Void, Never>?
    private let clock: any Clock<Duration>                      // seam 1: time
    private let search: @MainActor (String) async -> [String]   // seam 2: the network

    init(clock: any Clock<Duration> = ContinuousClock(),
         search: @escaping @MainActor (String) async -> [String]) {
        self.clock = clock
        self.search = search
    }

    private func scheduleSearch() {
        task?.cancel()
        task = Task {
            try? await clock.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            results = await search(query)
        }
    }
}

Now the test controls time. It types three characters, advances the clock to one millisecond before the 300 ms are up and proves that nothing has happened, then advances one more millisecond and proves that exactly one search ran, for the last keystroke. The whole test takes microseconds:

@Test @MainActor func debouncesToTheLastKeystroke() async {
    let clock = TestClock()
    var calls: [String] = []
    let model = SearchModel(clock: clock) { query in
        calls.append(query)
        return [query]
    }

    model.query = "s"; model.query = "sw"; model.query = "swi"

    await clock.advance(by: .milliseconds(299))
    #expect(calls.isEmpty)                    // nothing yet: still inside the window

    await clock.advance(by: .milliseconds(1))
    #expect(calls == ["swi"])                 // exactly one call, for the last keystroke
    #expect(model.results == ["swi"])
}

What interviewers listen for: That you say "hidden dependency" before you write anything; that you assert the negative case (no call at 299 ms); and that no line of the test waits on real time.