Debugging Focus Issues (tvOS + macOS)
June 4, 2026 · View on GitHub
UIFocusDebugger (LLDB Commands)
Available tvOS 11+. Use in Xcode debugger during a breakpoint.
Check current focus status
(lldb) po UIFocusDebugger.status()
Shows the currently focused item and its focus environment chain.
Check why a view can't receive focus
(lldb) po UIFocusDebugger.checkFocusability(for: myView)
Returns detailed explanation of why the view is or isn't focusable. Checks:
canBecomeFocusedreturn valueisHiddenalpha == 0isUserInteractionEnabled- Whether the view is in a window
- Whether an ancestor blocks focus
Simulate a focus update
(lldb) po UIFocusDebugger.simulateFocusUpdateRequest(from: myEnvironment)
Walks the preferred focus chain without actually moving focus. Shows which view WOULD receive focus.
Check focus group tree
(lldb) po UIFocusDebugger.checkFocusGroupTree(for: focusSystem)
Prints the entire focus group hierarchy.
List all commands
(lldb) po UIFocusDebugger.help()
_whyIsThisViewNotFocusable (Hidden Debug Method)
Not in public API but invaluable for debugging:
(lldb) po [myView _whyIsThisViewNotFocusable]
Returns human-readable list of issues:
- "userInteractionEnabled set to NO"
- "canBecomeFocused returns NO"
- "view is hidden"
- "alpha is 0"
- "view is obscured by another view"
- "ancestor has userInteractionEnabled = NO"
- "not in a window"
Launch Arguments
-UIFocusLoggingEnabled YES
Add to scheme's launch arguments. Logs every focus update to the console with:
- The preferred focus environment search chain
- Which view was selected and why
- Which views were considered and rejected
How to add
Xcode -> Product -> Scheme -> Edit Scheme -> Run -> Arguments -> "+" -> -UIFocusLoggingEnabled YES
Quick Look on UIFocusUpdateContext
In shouldUpdateFocus(in:) or didUpdateFocus(in:with:):
- Set breakpoint
- Select the
contextparameter - Click Quick Look (eye icon) or press Space
Shows visual diagram:
- Red: previously focused view (search start)
- Dotted red line: search path
- Purple: focusable UIView regions in search path
- Blue: focusable UIFocusGuide regions in search path
Debugging Focus Cascades (SwiftUI)
Focus cascades — where focus rapidly cycles through multiple items — are the hardest SwiftUI focus bugs to debug. Add structured logging to trace the exact sequence:
.onChange(of: focusedIndex) { old, new in
logger.debug("[Focus] \(old.map(String.init) ?? "nil") → \(new.map(String.init) ?? "nil") active=\(activeIndex.map(String.init) ?? "nil")")
}
What to look for in cascade logs:
nil→0→nil→0→nil→0= transient focus bouncing during pass-through navigation (see anti-pattern #29)10→9→8→7→6→5→4→3→2→1→0= rapid sequential cascade from.disabled()mass-toggle (see anti-pattern #25)0→10 scrollTo 10 10→9=scrollTofeedback loop disrupting focus (see anti-pattern #26)- Same value set repeatedly with
active=unchanged =@Observablesame-value mutation (see anti-pattern #27)
Production debug logging pattern:
// ViewModel — log state transitions
logger.debug("[Focus] displayedTopicIndex \(old) → \(new)")
logger.debug("[Load] loadTopic(\(index)) start — isLoading=true, clips cleared")
logger.debug("[Load] loadTopic(\(index)) complete — clips=\(clips.count)")
// View — log focus with surrounding state
logger.debug("[Focus] sidebarFocus \(old) → \(new), isGridFocusable=\(isGridFocusable), clips=\(clips.count)")
// Sidebar — log container focus
logger.debug("[Focus] isContainerFocused \(isFocused)")
// Grid — log what's being rendered
logger.debug("[Render] clips=\(clips.count), isLoading=\(isLoading), showing=\(clips.isEmpty ? (isLoading ? "skeleton" : "empty") : "grid")")
Leave these logs in during development — they're invaluable for debugging on-device focus issues that don't reproduce in the simulator.
Common Focus Issues Checklist
Focus doesn't move at all
- Is the target view focusable? (
canBecomeFocused, or is it a Button/Cell?) - Is the target view visible? (not hidden, alpha > 0, in window)
- Is
isUserInteractionEnabled = trueon the target AND all ancestors? - Is there a geometric path from current focus to target? (use Quick Look to see)
- Is
shouldUpdateFocus(in:)returning false somewhere in the chain?
Focus jumps to wrong item
- Missing
.focusSection()on horizontal ScrollViews? - Are items geometrically aligned? Focus follows nearest-neighbor in swipe direction.
- Is
remembersLastFocusedIndexPathconflicting with manual focus management? - Is
preferredFocusEnvironmentsreturning stale references?
Focus jitters / visual glitch
- Are you using
frame.widthin transform calculations? Cache the resting width. - Is
prepareForReuse()resetting all focus-related visual state? - Are focus animations using
addCoordinatedFocusingAnimations(not plain UIView.animate)? - Is
layer.zPositionmanaged to prevent overlap? - Are shadow animations using
CABasicAnimation(not UIView.animate)?
Focus lost after data reload
- Is
reloadData()called during an animation? - Is
remembersLastFocusedIndexPath+ offscreen reload causing stale index? - Is
shouldUpdateFocus(in:)blocking during reload? - Did you call
setNeedsFocusUpdate()+updateFocusIfNeeded()after reload? - Are you calling from the right focus environment (one that contains the focused view)?
SwiftUI focus not working
- Is
@FocusStateOptional when usingfocused($binding, equals:)? - Is
.focusScope(namespace)on an ancestor ofprefersDefaultFocus? - Is
.focusSection()applied to the container, not individual buttons? - Is
.disabled()being used? It removes views from tvOS focus chain. Gate the action inside the closure instead, or use dual@FocusStategating (anti-pattern #25). - Is
.focusable()added to a Button? Remove it.
Testing Focus
UI Test Utilities
extension XCUIRemote {
func press(_ button: XCUIRemote.Button, times: Int) {
for _ in 0..<times {
press(button)
Thread.sleep(forTimeInterval: 0.3)
}
}
}
extension XCUIApplication {
func focusedElement() -> XCUIElement {
return descendants(matching: .any).element(matching: NSPredicate(format: "hasFocus == true"))
}
}
Focus navigation test pattern
func testFocusNavigationBetweenRows() {
let remote = XCUIRemote.shared
let app = XCUIApplication()
// Navigate to first row item
remote.press(.select)
XCTAssertTrue(app.buttons["Row 0 Item 0"].hasFocus)
// Move down to second row
remote.press(.down)
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'Row 1'")).firstMatch.hasFocus)
// Move back up
remote.press(.up)
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'Row 0'")).firstMatch.hasFocus)
}
Important: Simulator vs Hardware
Focus behavior differs between Simulator and Apple TV hardware:
- Simulator allows arrow key "press and hold" — hardware uses swipe gestures
- Timing of focus animations differs
- Some focus edge cases only reproduce on hardware
- Always verify critical focus flows on a physical device
macOS Focus Debugging
Inspecting First Responder
(lldb) po NSApp.keyWindow?.firstResponder
// Shows the currently focused view
(lldb) po NSApp.keyWindow?.firstResponder?.nextResponder
// Shows next in responder chain
Debugging Key View Loop
Print the entire Tab order to verify correctness:
// Debug helper — call from lldb or a debug button
func printKeyViewLoop(from window: NSWindow) {
guard let first = window.initialFirstResponder ?? window.contentView else { return }
var current: NSView? = first
var visited = Set<ObjectIdentifier>()
repeat {
guard let view = current else { break }
let id = ObjectIdentifier(view)
if visited.contains(id) {
print("→ (loop complete, back to \(type(of: view)))")
break
}
visited.insert(id)
print("→ \(type(of: view)) canBecomeKeyView=\(view.canBecomeKeyView) acceptsFirstResponder=\(view.acceptsFirstResponder)")
current = view.nextValidKeyView
} while current != nil
}
NSWindow.initialFirstResponder
(lldb) po window.initialFirstResponder
// View that receives focus when window first opens
// nil = no view gets automatic focus
If initialFirstResponder is nil, the window opens with no focused view. Set it in Interface Builder or programmatically:
override func windowDidLoad() {
super.windowDidLoad()
window?.initialFirstResponder = searchField
}
Common macOS Focus Debugging Checklist
View won't accept Tab focus:
- Is
acceptsFirstResponderoverridden to returntrue? - Is
canBecomeKeyViewreturningtrue? - Is the view hidden, zero-alpha, or not in a window?
- Is
isHiddentrue on an ancestor? - Is the view in the key view loop? Check
nextKeyViewchain. - Is
recalculatesKeyViewLoopenabled and possibly excluding the view geometrically?
Focus ring not appearing:
- Is
focusRingTypeset to.none? - Is
.focusEffectDisabled()applied (SwiftUI)? - Is Full Keyboard Access enabled for non-text controls?
- Is the view actually the first responder? Check
window.firstResponder.
Focus jumps to wrong view after sheet/alert:
- Is the previous first responder saved before showing the sheet?
- Is
makeFirstRespondercalled in the completion handler? - Does the saved view still exist in the hierarchy?
Menu items don't respond to focused content:
- Is
focusedValueset on the view hierarchy? - Is
@FocusedValuereading the correct key in Commands? - Is the view in the key window (not main window behind a panel)?
- Is
focusedSceneValueneeded for multi-window?
Accessibility Inspector
Use Accessibility Inspector (Xcode > Open Developer Tool > Accessibility Inspector) to verify:
- Which element has keyboard focus
- Which element VoiceOver is reading
- Whether elements are properly labeled
- Focus order for Full Keyboard Access
Debug with Notifications
// Track all focus changes in a window
NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: nil, queue: .main
) { note in
let window = note.object as? NSWindow
print("Key window: \(window?.title ?? "nil"), firstResponder: \(window?.firstResponder ?? "nil" as Any)")
}