---
title: creaturesurvive/tvfocuskit
framework: Swift Package Catalog
role: article
path: packages/creaturesurvive/tvfocuskit
---

# 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`.

```sh 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:)`.

```swift 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`

```swift 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

```swift @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

```swift .requestFocus($focusedItem, to: results.first?.id, trigger: results.count) ```

### Debugging

```swift 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`:

```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`, `prefersDefaultFocus` and `resetFocus`. 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](CHANGELOG.md). Releases follow [Semantic Versioning](https://semver.org).

## 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](LICENSE) for details.

## Package Metadata

Repository: creaturesurvive/tvfocuskit

Default branch: main

README: README.md
