A tvOS ScrollView with one focusable view won't scroll
A settings pane in my tvOS app had a paragraph of explanatory text, and on
the television the bottom of it was off the screen. Pressing down on the
remote did nothing. Not a slow scroll, not a rubber-band — the view did not
move at all. The content was taller than the viewport, it was inside a
ScrollView, and the same code scrolls fine on iOS.
It read as a layout bug, so that is where I looked first. It is not one.
On tvOS a ScrollView scrolls by moving focus,
and that pane had exactly one thing focus could land on.
One target is as stuck as none
I already knew the zero case. An earlier bug in the same app
(AmbientCast, a tvOS app for watching a 3D
printer's camera) had a first-run screen whose content grew past the fold at
large text sizes, with nothing focusable in the scroll region. The note I
wrote at the time said a ScrollView with nothing focusable in it
cannot be scrolled, which was right.
What I had not worked out is that one is the same as none. The focus engine moves focus between candidates; a scroll is the side effect of reaching a candidate that is off screen. With a single target, the d-pad has nowhere to go, so nothing is ever revealed and the view never budges.
ELI5: what's the focus engine?
An Apple TV has no touchscreen and no pointer. Instead the system tracks one highlighted item at a time, and the remote moves that highlight around. The component deciding where the highlight goes next is the focus engine. If it cannot find somewhere to move, pressing the remote does nothing at all.
This is written down, just not where a SwiftUI developer would meet it.
UIKit has a protocol called
UIFocusItemScrollableContainer,
described as:
A type of focus item container that supports automatic scrolling of focusable content.
The focus engine scrolls the container to keep items onscreen as they become focused.
Every word of the mechanism is in there — focusable content, and
scrolling to keep items onscreen as they become focused. SwiftUI's
own ScrollView documentation talks about platform-appropriate
scroll gestures, which is true and no help here. You can write SwiftUI for
years without meeting the protocol that explains your bug.
The modifier that looks like the fix
Knowing the zero case, I had already "fixed" this pane once, by putting
.focusable(true) on the VStack wrapping the
content. That satisfies the rule as I had written it: something in the
scroll region takes focus. It also makes the entire pane a single target,
which is the bug.
// Looks like it obeys the rule. One target, so it cannot scroll.
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(title)
Text(intro)
ForEach(points, id: \.self) { Text($0) }
}
.focusable(true)
}
// Scrolls. Focus has somewhere to move, and the scroll follows it.
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(title).focusable(true)
Text(intro).focusable(true)
ForEach(points, id: \.self) { Text($0).focusable(true) }
}
}
That is the whole fix, and it is why the bug survives review. The two versions differ by where one modifier sits. One of them scrolls.
The honest cost: each of those rows now takes a focus ring when the d-pad lands on it, so prose picks up highlighting that prose does not normally have. That ring is also the only feedback telling a viewer the remote did something, so I left it alone.
Count the targets in the pane's emptiest state
The next pane I checked had zero. Its explanatory block carried a comment I had written saying it was deliberately not focusable, because there was nothing to activate and focus would only be a dead stop on the way past. That reasoning is backwards for a tvOS scroll view, where the dead stop is the mechanism rather than a cost.
The only other focus candidates in it were in-app purchase buttons, and those exist only when StoreKit has returned products to display.
ELI5: what's StoreKit?
Apple's framework for in-app purchases. An app asks it for the products it sells and gets back prices and names to display. The request goes to Apple's servers, so it can be slow, and it can come back empty — offline, or on a build installed without a store configuration.
So on any launch where that request was slow, offline, or made from a development install, the buttons were absent and the pane had no focus targets at all. Whether the pane carrying the app's privacy policy could be scrolled depended on a purchase API answering.
That is the question worth asking, and it is not "is this focusable." It is how many focus targets does this scroll view have in its emptiest state — with no products, no history, no content loaded. Zero and one both mean stuck. The count is greppable; the behavior is not.
Scrolling back up needs a target above
The third pane taught me the rule is sharper still, and my first fix for it was wrong in a useful way.
That screen has a title, a sentence saying what the app is, then three requirement rows. I made the three rows individually focusable, which is the fix above, and the remote could now walk down the list with the scroll following. It looked done.
Then I pressed up on the device and could not get back to the title. A scroll view only ever scrolls to reveal a view that takes focus, and the title sits above the topmost focusable row. Press up from the first row and there is nothing to move to, so once large text had pushed the title off the top it was gone for good.
ELI5: what is Large Text?
An accessibility setting that makes every label in every app bigger, so someone who finds the default hard to read can still use the device. Apps are supposed to reflow to accommodate it. The practical effect for a developer is that any screen's content can get much taller than it was when you designed it.
So: anything that has to be readable must be a focus target itself, or sit below one. Making the title and the description focusable took that screen to five targets and up walks back to the top.
It also explained a failed experiment from weeks earlier. I had tried
prefersDefaultFocus inside a focusScope to stop
the focus engine from grabbing the content block at launch, and the output
was pixel-identical. The problem was never which view focus starts on.
A screenshot cannot see any of this
This is the part that cost the most. I had swept every screen in the app at the largest accessibility text size, rendering each one and looking at it. That sweep passed the first of these panes outright, and later passed a fix for the third that was still broken.
A render tells you where the fold is. It does not tell you whether you can get past it. "Visible at launch" and "reachable" are different claims, and only a remote separates them — which is exactly the instrument a headless build does not have. The sweep was not sloppy; it was answering a question that could not reveal the defect.
What works instead is counting. Walk each scroll view, count the descendants that can take focus, and ask what the count is with every optional piece of content missing. Two of mine came out at one and zero.
What I'd tell you to check first
If a tvOS ScrollView will not move:
- Count the focusable descendants. Zero and one are both stuck. This is the whole bug, most of the time.
- Check where
.focusable()sits. On the container it collapses the content to one target and looks like a fix. It belongs on the rows. - Count again with the optional content gone. If your only targets are buttons behind a network call, a purchase, or a non-empty list, the pane is unscrollable whenever that comes back empty.
- Check you can scroll back. Anything above the topmost focusable view is unreachable once it is off screen.
- Don't trust a screenshot. Rendering at a big text size shows you overflow, not reachability. Press the d-pad.
The general version, and the reason I had this bug twice: a rule written from one failing case tends to encode that case rather than the mechanism. "Make something focusable" was a true description of the fix for an empty scroll view and a wrong description of why it worked. The mechanism is that scrolling is a side effect of focus moving, and everything above follows from it in a line.
Someone reported the one-target version of this
on the Apple
Developer Forums in March 2024 — a ScrollView holding one long
Text with .focusable() on it, scrolling in the
Xcode preview and refusing to move on the device. Another developer
confirmed it in March 2025, a third posted a UITextView
workaround that August, and nobody has said why it happens.
The one-target case was still unscrollable for me on an Apple TV 4K (3rd generation) running tvOS 27; the app builds back to tvOS 17.