Skip to content

gitdave/SDL3-SwiftUI

Repository files navigation

SDL3 + SwiftUI Coexistence Demo

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.


The Problem

SDL3 and SwiftUI both assume they own the application lifecycle. When they coexist, three specific collisions occur:

1. SDL steals the keyboard (iOS / tvOS)

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 sizeThatFits so SwiftUI sizes it like a normal TextField (UIKit's default intrinsic height is ~44pt; we use font.lineHeight + 4)
  • Locks/unlocks in editingDidBegin/editingDidEnd targets
  • Unlocks before onCommit so the Return key can actually move focus

2. Game controller input fires game actions while SwiftUI dialog is open (tvOS)

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 true when presenting the dialog → UIKit handles all controller input
  • Set false when dismissing → SDL handles all controller input again
func setControllerRouting(enabled: Bool) {
    guard let vc = hostWindow?.rootViewController
                    as? GCEventViewController else { return }
    vc.controllerUserInteractionEnabled = enabled
}

3. B button dismisses the dialog but the game never finds out

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:

  1. Add .onExitCommand to the SwiftUI view to intercept the B button and route it through the normal cancel path.

  2. Add .onDisappear as 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 Layout

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.

Local-State Pattern

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.


Building

Add all files to an Xcode project. In Build Settings:

  • Set SWIFT_OBJC_BRIDGING_HEADER to a bridging header that imports AppBridge.h
  • Or use a module map. The ObjC file imports SDLSwiftUIDemo-Swift.h (generated).
  • Link SDL3.xcframework for 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.

About

How to work around keyboard and game controller contention between SDL3 and SwiftUI

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors