creaturesurvive/tvfocuskit
Focus tools for tvOS SwiftUI that fix what the focus engine gets wrong in real apps: rows that
The problems
| Problem in plain SwiftUI | TVFocusKit | | --- | --- | | Moving up to a row you scrolled lands on the geometrically nearest item, not the one you left. | FocusShelf and .remembersFocus(:) return to the remembered item. A shelf's first entry still uses the nearest item, as users expect. | | Setting a @FocusState in onAppear or after data loads often does nothing, because the target isn't focusable yet. | .requestFocus(:to:trigger:) retries each frame until focus lands or it times out. | | The Menu button leaves the screen from deep in a list. The Apple TV app first returns to the top. | ShelfStack returns focus to the start of the first shelf, then lets Menu perform its normal action. | | Focus moves scroll the list minimally, leaving the focused row half visible. | ShelfStack scrolls the focused shelf to a fixed anchor (the top by default). | | Focused items scale up and get clipped by the scroll view. | Shelves disable clipping and reserve room for the lift and its shadow. | | "Why can't I move right?" can only be answered with LLDB. | FocusMonitor records every focus change and every blocked move. .focusDebugOverlay() outlines the focused item and lists recent events on screen. |
Verified on the tvOS simulator
Example/ is a tvOS app with UI tests that drive it with the Siri Remote (XCUIRemote):
- Remembered focus: in a shelf the test goes to item 2, moves down and right to item 5 of
row 1, then back up, and lands on r0i2. The same moves with plain SwiftUI rows land on r0i4. The test records the plain result so the comparison stays honest.
- Menu: from row 3, Menu returns to
r0i0, and a second press leaves the app. It also
works from a visible row and from partway along the first row.
- Late content: focus requested for a row that loads 300 ms later lands on the requested
item.
- Monitor: pressing left at the first item is logged as
left: blocked at r0i0.
cd Example && xcodegen generate
xcodebuild test -project TVFocusDemo.xcodeproj -scheme TVFocusDemo \
-destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)"swift test covers the pure logic: focus memory, the focus request retry loop, the Menu state machine, and the monitor's event window.
The README screenshots are captured by UI tests in Example/; Scripts/screenshots.sh regenerates them.
API
FocusShelf
A horizontal row of focusable items. The content closure must return a focusable view, such as a Button or NavigationLink, because each item is bound with .focused(_:equals:).
FocusShelf(episodes) { episode in
Button { play(episode) } label: { EpisodeCard(episode) }
} header: {
Label("Up Next", systemImage: "play.circle").font(.headline)
}
.focusShelfStyle({
var style = FocusShelfStyle()
style.spacing = 40
return style
}())A shelf works on its own, too. Inside a ShelfStack it also reports focus to the stack.
ShelfStack
ShelfStack(rows, anchor: .top) { row in
FocusShelf(row.title, row.items) { item in ItemButton(item) }
} header: {
HeroBanner()
}Pass anchor: nil to keep the system's minimal scrolling.
Remembered focus for any container
@FocusState private var focusedEpisode: Episode.ID?
LazyVGrid(columns: columns) {
ForEach(episodes) { episode in
EpisodeCard(episode).focused($focusedEpisode, equals: episode.id)
}
}
.focusSection()
.remembersFocus($focusedEpisode)Reliable programmatic focus
.requestFocus($focusedItem, to: results.first?.id, trigger: results.count)Debugging
ContentView()
#if DEBUG
.focusDebugOverlay()
#endif
FocusMonitor.shared.logsEvents = true // also log to Console (subsystem TVFocusKit)<p align="center"> <img src="Screenshots/debug-overlay.jpg" alt="The focus debug overlay outlining the focused card and listing focus moves, including a blocked move to the left" width="100%"> </p>
Installation
Add TVFocusKit to your Package.swift:
dependencies: [
.package(url: "https://github.com/CreatureSurvive/TVFocusKit.git", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["TVFocusKit"]),
]Or in Xcode, choose File › Add Package Dependencies… and enter https://github.com/CreatureSurvive/TVFocusKit.
Requirements
| Platform | Minimum | | --- | --- | | iOS | 17.0 | | macOS | 14.0 | | tvOS | 17.0 | | visionOS | 1.0 |
Swift 6.1 (Xcode 16.4) or later, in Swift 6 language mode. No third-party dependencies.
TVFocusKit is built for tvOS. It also compiles on the other platforms so shared code builds, but focus sections and the Menu behavior only take effect on tvOS and macOS.
Notes
- Returning to the start uses
focusScope,prefersDefaultFocusandresetFocus. Assigning a
@FocusState can't pull focus back across shelves the focus engine keeps scrolled into view.
- A shelf's first item marks the start. A header view above the shelves (a hero banner, for
example) isn't part of it.
Changelog
See CHANGELOG.md. Releases follow Semantic Versioning.
Contributing
Issues and pull requests are welcome. Please run swift test before opening a pull request, and add tests for new behavior.
License
Available under the MIT license. See LICENSE for details.
Package Metadata
Repository: creaturesurvive/tvfocuskit
Default branch: main
README: README.md