Minimal example showing how to present a SwiftUI dialog from a C + SDL3 application on iOS, tvOS, and macOS, with working text fields and game controller input.
SDL3 and SwiftUI both assume they own the application lifecycle. When they coexist, three specific collisions occur:
SDL3 observes UITextFieldTextDidChangeNotification. Every time the user
types a character, SDL calls SDL_StartTextInputWithProperties, which calls
becomeFirstResponder on SDL's hidden UITextField. This forces
resignFirstResponder on whichever field the user is actually typing in,
dismissing the keyboard mid-sentence.
Fix: KeyLockedTextField — a UITextField subclass that overrides
canResignFirstResponder to return false while editing. A static flag
(anotherFieldBecomingActive) allows the field to resign when the user
taps a different field, while still blocking SDL's programmatic
becomeFirstResponder.
class KeyLockedTextField: UITextField {
var isLocked: Bool = false
static var anotherFieldBecomingActive = false
override var canResignFirstResponder: Bool {
return !isLocked || KeyLockedTextField.anotherFieldBecomingActive
}
override func becomeFirstResponder() -> Bool {
KeyLockedTextField.anotherFieldBecomingActive = true
let result = super.becomeFirstResponder()
KeyLockedTextField.anotherFieldBecomingActive = false
return result
}
}SDLSafeTextField wraps this in a UIViewRepresentable that also:
- Overrides
sizeThatFitsso SwiftUI sizes it like a normal TextField (UIKit's default intrinsic height is ~44pt; we usefont.lineHeight + 4) - Locks/unlocks in
editingDidBegin/editingDidEndtargets - Unlocks before
onCommitso the Return key can actually move focus
On tvOS, SDL receives all GCController events via GCEventViewController.
When a SwiftUI dialog is open, pressing the A button both activates a SwiftUI
button AND fires an SDL gamepad event to the game.
Fix: Toggle GCEventViewController.controllerUserInteractionEnabled:
- Set
truewhen presenting the dialog → UIKit handles all controller input - Set
falsewhen dismissing → SDL handles all controller input again
func setControllerRouting(enabled: Bool) {
guard let vc = hostWindow?.rootViewController
as? GCEventViewController else { return }
vc.controllerUserInteractionEnabled = enabled
}On tvOS, overFullScreen modal presentation style bypasses
UIPresentationController, so presentationControllerDidDismiss never fires
when the user presses the B button. The view controller is dismissed by the
system, the SwiftUI view disappears, but the C callback is never called —
leaving the game stuck waiting for a result.
Fix: Two-part approach:
-
Add
.onExitCommandto the SwiftUI view to intercept the B button and route it through the normal cancel path. -
Add
.onDisappearas a safety net for any other unexpected dismissal. Use a guard so it's a no-op if the buttons already handled the close:
.onDisappear {
Task { @MainActor in
bridge.handleUnexpectedDismissal() // no-op if already closed
}
}func handleUnexpectedDismissal() {
guard pendingCompletion != nil else { return }
closeDialog(confirmed: false, playerName: "", matchName: "")
}| File | Purpose |
|---|---|
main.c |
SDL3 callback API (SDL_AppInit/Event/Iterate/Quit). Opens dialog on A/Return. Receives result via on_dialog_closed(). |
AppBridge.h |
C-visible interface for the ObjC glue. |
AppBridge.m |
ObjC glue. Extracts the native window from SDL3 properties and calls into the Swift singleton. |
DialogBridge.swift |
Swift singleton. Presents/dismisses the UIHostingController or NSPanel. Manages controller routing and callback. |
KeyLockedTextField.swift |
KeyLockedTextField subclass + SDLSafeTextField UIViewRepresentable. |
DemoDialogView.swift |
SwiftUI dialog with two text fields and OK / Cancel buttons. |
All text fields use local state committed only on Return key press:
@State private var localPlayerName: String = ""
SDLSafeTextField(text: $localPlayerName, ...) {
// Only called on Return — not on every keystroke
actualPlayerName = localPlayerName
}On macOS, use .onSubmit instead of .onChange for the same reason —
.onChange fires on every keystroke and would hammer any network layer
(e.g. Bonjour TXT record updates) unnecessarily.
Add all files to an Xcode project. In Build Settings:
- Set
SWIFT_OBJC_BRIDGING_HEADERto a bridging header that importsAppBridge.h - Or use a module map. The ObjC file imports
SDLSwiftUIDemo-Swift.h(generated). - Link
SDL3.xcframeworkfor your target platforms.
The SDL3 SDL_main macro replaces main(), so you do not need a separate
@main Swift entry point — SDL_AppInit is the entry point.