Make LearnMoreModal focusable and scrollable on tvOS#1913
Make LearnMoreModal focusable and scrollable on tvOS#1913henryauryn wants to merge 6 commits intojellyfin:mainfrom
Conversation
|
Testing this now. Could you send a video of what you are seeing that's off? From my end, this is all working how I would expect this so I think you might've nailed it! As a note, right now only the @ViewBuilder
private var videoPlayerSection: some View {
Section(L10n.videoPlayer) {
#if os(iOS)
Picker(L10n.videoPlayerType, selection: $videoPlayerType)
ChevronButton(L10n.nativePlayer) {
router.route(to: .nativePlayerSettings)
}
#else
ListRowMenu(L10n.videoPlayerType, selection: $videoPlayerType)
#endif
ChevronButton(L10n.videoPlayer) {
router.route(to: .videoPlayerSettings)
}
ChevronButton(L10n.playbackQuality) {
router.route(to: .playbackQualitySettings)
}
} learnMore: {
LabeledContent(
"Swiftfin",
value: L10n.playerSwiftfinDescription
)
LabeledContent(
L10n.native,
value: L10n.playerNativeDescription
)
LabeledContent(
"Other",
value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent ullamcorper mauris et turpis vestibulum laoreet et eu dolor. Proin convallis, nisl vel imperdiet sollicitudin, enim lorem semper elit, sed fermentum mauris dui in risus. Morbi non ipsum in nisl maximus posuere. Curabitur sollicitudin auctor risus vel hendrerit. Nullam eu orci neque. Nulla tristique augue id mauris porttitor vulputate. Etiam non est at lorem lacinia imperdiet."
)
LabeledContent(
"Stuff",
value: "Curabitur id elit non odio efficitur efficitur. Cras vitae magna sit amet tellus efficitur finibus in vel dolor. Nam eget nibh consectetur, ullamcorper nisl in, ornare nulla. Morbi ex turpis, tristique non purus non, laoreet tincidunt nibh. Cras eu tellus sapien. Donec ullamcorper, felis id semper fermentum, justo ante mattis velit, non rhoncus tortor ex et tellus. Proin rutrum ex et ipsum facilisis bibendum."
)
}
}Only bit that is off is leaving the modal should return focus to the last focused button opposed to the closed object in the grid |
clip.movYeah sure, here it is. It's a small thing but actually I think that your note about the controlling the focus return state should fix it, as this small bug of the modal appearing on other non-videoPlayerTypePicker items seems to only be able to be recreated when the user manages to swipe off the modal onto a form item that isn't the listPicker. Your fix would mean that the only source (and destination) would be the form item that actually owns the learn more content, making this bug un-reproducable. I'll get on finishing that :) |
|
Gotcha! I also think that some of that was cleaned up in: https://github.com/jellyfin/Swiftfin/pull/1912/changes#diff-26955c4290c5eaf464de2dc3600819bb49af254de58f29a377a3ee3f2d4977e3 I merged with Main if you want to pull latest. I think there are still some scenario where this might get funky but forcing focus back to the source button should be a better user experience and help avoid some interactions like that. Looking at this, I'm trying to figure if always focusable is better or if it should be focusable only if it needs to scroll. I personally like the latter but I think the former is less weird from a user perspective of "Why can I only sometimes focus these?" |
|
Cool, will do. Yeah that's a really good question, I mean if we defer to Apple I feel like the tvOS philosophy is if it's not functional, it shouldn't be focusable. Although I agree with you, my first hunch is that the user will assume it's something to fiddle with even if it doesn't scroll. |
I like that thinking! I think you put that into words better than I have been haha. I'd look at the
For changing focus, I would look at the .if(needsScrolling) { view in
view
.focusGuide(
focusGuide,
tag: "button",
left: "learnMoreModal"
)
}I hope that helps! Let me know if you have anything else I can help out with |
|
Okay so I've made some changes and I think it's working well.
Basically it was just unfeasible, and as it stands now focus just jumps back to whatever is the closest form item y-axis-wise. I'm not too sure how to overcome this. Please let me know if I shouldn't have reworked an existing modifier like that, especially since it's called by other views in the project - I tested them and I can't see any scenario where it affects existing views (all new parameters default to nil/false) but I'm new to open source and swift (relatively, 4-5 months) so please let me know if I've overstepped/missed stuff. :) |
|
Looking at this now! Sorry, I started this morning over coffee but I had some fires to put out today so I'm just now getting back to this. I'll ping you as I have something! |
|
Cool, thank you, no worries - it's getting on for midnight here in London anyways so no rush, I'll be seeing it tomorrow :) |
|
I am seeing what was giving you trouble. This view is primarily vertical and making a horizontal How do we feel about never making it focusable and looking something like Marquee? Early version, this is what I have now. Not sold on the speed but that's configurable after the fact: Simulator.Screen.Recording.-.Apple.TV.-.2026-02-17.at.15.55.47.movThe Good
The Bad
CodeForm//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2026 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension FocusedValues {
@Entry
var formLearnMore: AnyView? = nil
}
// MARK: - Form Overloads
func Form(
systemImage: String,
@ViewBuilder content: @escaping () -> some View
) -> some View {
PlatformForm(content: content) {
Image(systemName: systemImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
}
func Form(
image: ImageResource,
@ViewBuilder content: @escaping () -> some View
) -> some View {
PlatformForm(content: content) {
Image(image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
}
func Form(
@ViewBuilder content: @escaping () -> some View,
@ViewBuilder image: @escaping () -> some View
) -> some View {
PlatformForm(content: content, image: image)
}
// MARK: - Platform Form
private struct PlatformForm<Image: View, Content: View>: PlatformView {
@FocusedValue(\.formLearnMore)
private var focusedLearnMore
private let content: Content
private let image: Image
init(
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder image: @escaping () -> Image
) {
self.content = content()
self.image = image()
}
var iOSView: some View {
Form {
content
}
.navigationBarTitleDisplayMode(.inline)
}
var tvOSView: some View {
HStack {
descriptionView
.frame(maxWidth: .infinity)
Form {
content
}
.padding(.top)
.backport
.scrollClipDisabled()
}
}
@ViewBuilder
private var descriptionView: some View {
ZStack {
image
if let focusedLearnMore {
learnMoreModal(focusedLearnMore)
}
}
.animation(.linear(duration: 0.2), value: focusedLearnMore == nil)
}
@ViewBuilder
private func learnMoreModal(_ content: AnyView) -> some View {
Marquee(axis: .vertical, speed: 100, delay: 3, fade: 80) {
VStack(alignment: .leading, spacing: 16) {
content
.labeledContentStyle(LearnMoreLabeledContentStyle())
.foregroundStyle(Color.primary, Color.secondary)
}
.edgePadding()
}
.clipShape(RoundedRectangle(cornerRadius: 20))
.background {
RoundedRectangle(cornerRadius: 20)
.fill(Material.thick)
}
.padding()
}
}Marquee//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2026 Jellyfin & Jellyfin Contributors
//
// This component based on https://github.com/SwiftUIKit/Marquee
import SwiftUI
private enum MarqueeState {
case idle
case animating
}
struct Marquee<Content: View>: View {
@Environment(\.isFocused)
private var isFocused: Bool
@State
private var contentSize: CGSize = .zero
@State
private var isAppear = false
@State
private var state: MarqueeState = .idle
private let axis: Axis
private let speed: CGFloat
private let delay: Double
private let gap: CGFloat
private let animateWhenFocused: Bool
private let fade: CGFloat
private let content: Content
var body: some View {
ViewThatFits(in: axis == .horizontal ? .horizontal : .vertical) {
content
GeometryReader { proxy in
VStack(alignment: .leading) {
if isAppear {
ZStack {
content
.onSizeChanged { size, _ in
let lengthChanged = (axis == .horizontal ? contentSize.width : contentSize.height) !=
(axis == .horizontal ? size.width : size.height)
self.contentSize = size
if lengthChanged {
resetAnimation(proxy: proxy)
}
}
.fixedSize(horizontal: axis == .horizontal, vertical: true)
.marqueeOffset(
x: axis == .horizontal ? offset(proxy: proxy) : 0,
y: axis == .horizontal ? 0 : offset(proxy: proxy)
)
.frame(
maxWidth: axis == .horizontal ? nil : .infinity,
maxHeight: axis == .horizontal ? .infinity : nil
)
if (axis == .horizontal ? contentSize.width : contentSize.height) >=
(axis == .horizontal ? proxy.size.width : proxy.size.height)
{
content
.fixedSize(horizontal: axis == .horizontal, vertical: true)
.marqueeOffset(
x: axis == .horizontal ? offset(proxy: proxy) +
(axis == .horizontal ? contentSize.width : contentSize.height) + gap(proxy) : 0,
y: axis == .horizontal ? 0 : offset(proxy: proxy) +
(axis == .horizontal ? contentSize.width : contentSize.height) + gap(proxy)
)
.frame(
maxWidth: axis == .horizontal ? nil : .infinity,
maxHeight: axis == .horizontal ? .infinity : nil
)
}
}
} else {
EmptyView()
}
}
.padding(axis == .horizontal ? .leading : .top, fade)
.onAppear {
// There is the possibility that `proxy.size` is `.zero` on `onAppear`. This can happen e.g.
// inside a `NavigationView` or within a `.sheet`. In those cases we do not want to
// initialize the animation yet as we need the proper size first.
// This use-case is handled by reacting to changes to the relevant size below.
guard (axis == .horizontal ? proxy.size.width : proxy.size.height) != .zero else {
return
}
initializeAnimation(proxy: proxy)
}
.onChange(of: axis == .horizontal ? proxy.size.width : proxy.size.height) { _ in
guard !isAppear, (axis == .horizontal ? proxy.size.width : proxy.size.height) != .zero else {
return
}
initializeAnimation(proxy: proxy)
}
.onDisappear {
self.isAppear = false
}
.onChange(of: isFocused) { newFocused in
resetAnimation(proxy: proxy, isFocused: newFocused)
}
.onChange(of: speed) { newSpeed in
resetAnimation(proxy: proxy, speed: newSpeed)
}
.onChange(of: delay) { newDelay in
resetAnimation(proxy: proxy, delay: newDelay)
}
}
.frame(height: axis == .horizontal ? contentSize.height : nil)
.clipped()
.mask {
GeometryReader { proxy in
let length = axis == .horizontal ? proxy.size.width : proxy.size.height
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: fade / length),
.init(color: .black, location: 1 - (fade / length)),
.init(color: .clear, location: 1),
],
startPoint: axis == .horizontal ? .leading : .top,
endPoint: axis == .horizontal ? .trailing : .bottom
)
}
}
.padding(axis == .horizontal ? .leading : .top, -fade)
}
}
private func initializeAnimation(proxy: GeometryProxy) {
isAppear = true
resetAnimation(proxy: proxy)
}
private func offset(proxy: GeometryProxy) -> CGFloat {
switch state {
case .idle:
0
case .animating:
-((axis == .horizontal ? contentSize.width : contentSize.height) + gap(proxy))
}
}
private func resetAnimation(
proxy: GeometryProxy,
speed: Double? = nil,
delay: Double? = nil,
isFocused: Bool? = nil
) {
let speed = speed ?? self.speed
let isFocused = isFocused ?? self.isFocused
if speed == 0 || speed == Double.infinity || (animateWhenFocused && !isFocused) {
stopAnimation()
} else {
startAnimation(
speed: speed,
delay: delay ?? self.delay,
proxy: proxy
)
}
}
private func startAnimation(
speed: Double,
delay: Double,
proxy: GeometryProxy
) {
let contentFits = (axis == .horizontal ? contentSize.width : contentSize.height) <
(axis == .horizontal ? proxy.size.width : proxy.size.height)
if contentFits {
stopAnimation()
return
}
let duration = ((axis == .horizontal ? contentSize.width : contentSize.height) + gap(proxy)) / speed
withAnimation(.linear(duration: 0.005)) {
self.state = .idle
withAnimation(
Animation
.linear(duration: duration)
.delay(delay)
.repeatForever(autoreverses: false)
) {
self.state = .animating
}
}
}
private func gap(_ proxy: GeometryProxy) -> CGFloat {
max(
0,
(axis == .horizontal ? proxy.size.width : proxy.size.height) - (axis == .horizontal ? contentSize.width : contentSize.height)
) + gap
}
private func stopAnimation() {
withAnimation(.linear(duration: 0.005)) {
self.state = .idle
}
}
}
// Reference: https://swiftui-lab.com/swiftui-animations-part2/
private extension View {
func marqueeOffset(x: CGFloat, y: CGFloat) -> some View {
modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
}
private struct _OffsetEffect: GeometryEffect {
var offset: CGSize
var animatableData: CGSize.AnimatableData {
get { CGSize.AnimatableData(offset.width, offset.height) }
set { offset = CGSize(width: newValue.first, height: newValue.second) }
}
func effectValue(size _: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
}
}
extension Marquee {
// MARK: - Text
init(
_ title: String,
axis: Axis = .horizontal,
speed: CGFloat = 60.0,
delay: Double = 2.0,
gap: CGFloat = 50.0,
animateWhenFocused: Bool = false,
fade: CGFloat = 10.0
) where Content == Text {
self.axis = axis
self.speed = speed
self.delay = delay
self.gap = gap
self.animateWhenFocused = animateWhenFocused
self.fade = fade
content = Text(title)
}
// MARK: - View
init(
axis: Axis = .horizontal,
speed: CGFloat = 60.0,
delay: Double = 2.0,
gap: CGFloat = 50.0,
animateWhenFocused: Bool = false,
fade: CGFloat = 10.0,
@ViewBuilder content: () -> Content
) {
self.axis = axis
self.speed = speed
self.delay = delay
self.gap = gap
self.animateWhenFocused = animateWhenFocused
self.fade = fade
self.content = content()
}
} |
|
Honestly, if I was a blind user who hadn't seen the code side, I'd say it should be manually focusable, but I think the Marquee is the best thing, simpler and more idiomatic for the rest of the project and still a decent UX. I feel like managing to avoid dodgy/temperamental focus management that who knows will flicker/break where is a good trade-off. I'll play around with your changes tomorrow. As a side-note, as my first FOSS experience (as a jelly/swiftFin user and cs student who's individually published apps but no open source/big project stuff) I'm really enjoying this. Your codebase is crazy clever and I'm learning so much. Obviously the Issues tab has a lot going on, after this issue is there any area or direction you/the team need people to spend time on the most? Can't guarantee I'll be that useful, still am learning, but I can try haha |
Haha a lot of this definitely predates my involvement so I can't take credit for it. We have had a lot of talented people who have contributed to get us where we are now but most of the brains behind the operations here is @LePips.
Anything in there that looks interesting to you! If you have any questions you can @ me either on the issue or here: #1503. I would suggest starting here: These are good quick wins that we just haven't had the hours to get to. I've been meaning to do #1523 which is primarily testing and reading documentation on what filters are for what. For a more code one, #711 is assigning our playback speed onSet in the |
|
Okay, this is what I have in mind for V2. This is way to fast but the concept is there: Simulator.Screen.Recording.-.Apple.TV.-.2026-02-17.at.17.07.04.movFrom the WebDev world, this looks like horizontal = POC for Component//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2026 Jellyfin & Jellyfin Contributors
//
import SwiftUI
// MARK: - Mode
enum AutoScrollMode {
case wrap
case bounce
}
// MARK: - AutoScroll
struct AutoScroll<Content: View>: View {
@Environment(\.isFocused)
private var isFocused: Bool
@State
private var contentSize: CGSize = .zero
@State
private var containerSize: CGSize = .zero
@State
private var offset: CGFloat = 0
@State
private var isAppear = false
@State
private var isAnimating = false
private let axis: Axis
private let mode: AutoScrollMode
private let speed: CGFloat
private let delay: Double
private let fade: CGFloat
private let gap: CGFloat
private let animateWhenFocused: Bool
private let content: Content
init(
axis: Axis,
mode: AutoScrollMode,
speed: CGFloat,
delay: Double,
fade: CGFloat,
gap: CGFloat = 0,
animateWhenFocused: Bool = false,
@ViewBuilder content: () -> Content
) {
self.axis = axis
self.mode = mode
self.speed = speed
self.delay = delay
self.fade = fade
self.gap = gap
self.animateWhenFocused = animateWhenFocused
self.content = content()
}
var body: some View {
ViewThatFits(in: axis == .horizontal ? .horizontal : .vertical) {
content
switch mode {
case .wrap:
wrapContent
case .bounce:
bounceContent
}
}
}
// MARK: - Fade Mask
@ViewBuilder
private var fadeMask: some View {
GeometryReader { proxy in
let length = axis == .horizontal ? proxy.size.width : proxy.size.height
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: fade / length),
.init(color: .black, location: 1 - fade / length),
.init(color: .clear, location: 1),
],
startPoint: axis == .horizontal ? .leading : .top,
endPoint: axis == .horizontal ? .trailing : .bottom
)
}
}
// MARK: - Wrap Mode
@ViewBuilder
private var wrapContent: some View {
GeometryReader { proxy in
VStack(alignment: .leading) {
if isAppear {
ZStack {
content
.onSizeChanged { size, _ in
let widthChanged = contentSize.width != size.width
contentSize = size
if widthChanged {
resetWrapAnimation(proxy: proxy)
}
}
.fixedSize()
.modifier(_OffsetEffect(offset: CGSize(width: wrapOffset(proxy: proxy), height: 0)))
.frame(maxHeight: .infinity)
if contentSize.width >= proxy.size.width {
content
.fixedSize()
.modifier(_OffsetEffect(offset: CGSize(
width: wrapOffset(proxy: proxy) + contentSize.width + effectiveGap(proxy),
height: 0
)))
.frame(maxHeight: .infinity)
}
}
} else {
EmptyView()
}
}
.padding(.leading, fade)
.onAppear {
guard proxy.size.width != .zero else { return }
isAppear = true
resetWrapAnimation(proxy: proxy)
}
.onChange(of: proxy.size.width) { _ in
guard !isAppear, proxy.size.width != .zero else { return }
isAppear = true
resetWrapAnimation(proxy: proxy)
}
.onDisappear { isAppear = false }
.onChange(of: isFocused) { newFocused in
resetWrapAnimation(proxy: proxy, isFocused: newFocused)
}
.onChange(of: speed) { _ in
resetWrapAnimation(proxy: proxy)
}
.onChange(of: delay) { _ in
resetWrapAnimation(proxy: proxy)
}
}
.frame(height: contentSize.height)
.clipped()
.mask { fadeMask }
.padding(.leading, -fade)
}
private func wrapOffset(proxy: GeometryProxy) -> CGFloat {
isAnimating ? -(contentSize.width + effectiveGap(proxy)) : 0
}
private func effectiveGap(_ proxy: GeometryProxy) -> CGFloat {
max(0, proxy.size.width - contentSize.width) + gap
}
private func resetWrapAnimation(
proxy: GeometryProxy,
isFocused: Bool? = nil
) {
let isFocused = isFocused ?? self.isFocused
if speed == 0 || speed == .infinity || (animateWhenFocused && !isFocused) {
withAnimation(.linear(duration: 0.005)) {
isAnimating = false
}
} else {
let contentFits = contentSize.width < proxy.size.width
if contentFits {
withAnimation(.linear(duration: 0.005)) {
isAnimating = false
}
return
}
let duration = (contentSize.width + effectiveGap(proxy)) / speed
withAnimation(.linear(duration: 0.005)) {
isAnimating = false
withAnimation(
.linear(duration: duration)
.delay(delay)
.repeatForever(autoreverses: false)
) {
isAnimating = true
}
}
}
}
// MARK: - Bounce Mode
@ViewBuilder
private var bounceContent: some View {
GeometryReader { proxy in
content
.fixedSize(horizontal: false, vertical: true)
.onSizeChanged { size, _ in
contentSize = size
containerSize = proxy.size
}
.modifier(_OffsetEffect(offset: CGSize(width: 0, height: offset)))
}
.clipped()
.mask { fadeMask }
.onAppear { scrollDown() }
.onChange(of: contentSize.height) { _ in
if offset == 0 { scrollDown() }
}
}
private var overflow: CGFloat {
max(0, contentSize.height - containerSize.height)
}
private func scrollDown() {
guard overflow > 0, speed > 0 else { return }
let duration = Double(overflow / speed)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
withAnimation(.easeInOut(duration: duration)) {
offset = -overflow
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
scrollUp()
}
}
}
private func scrollUp() {
guard overflow > 0, speed > 0 else { return }
let duration = Double(overflow / speed)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
withAnimation(.easeInOut(duration: duration)) {
offset = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
scrollDown()
}
}
}
}
// MARK: - Offset Effect
private struct _OffsetEffect: GeometryEffect {
var offset: CGSize
var animatableData: CGSize.AnimatableData {
get { CGSize.AnimatableData(offset.width, offset.height) }
set { offset = CGSize(width: newValue.first, height: newValue.second) }
}
func effectValue(size _: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
}
}Then we just have something like: struct Marquee: View {
private let title: String
private let speed: CGFloat
private let delay: Double
private let gap: CGFloat
private let animateWhenFocused: Bool
private let fade: CGFloat
init(
_ title: String,
speed: CGFloat = 60.0,
delay: Double = 2.0,
gap: CGFloat = 50.0,
animateWhenFocused: Bool = false,
fade: CGFloat = 10.0
) {
self.title = title
self.speed = speed
self.delay = delay
self.gap = gap
self.animateWhenFocused = animateWhenFocused
self.fade = fade
}
var body: some View {
AutoScroll(
axis: .horizontal,
mode: .wrap,
speed: speed,
delay: delay,
fade: fade,
gap: gap,
animateWhenFocused: animateWhenFocused
) {
Text(title)
}
}
}struct Ticker<Content: View>: View {
private let speed: CGFloat
private let delay: Double
private let fade: CGFloat
private let content: Content
init(
speed: CGFloat = 60,
delay: Double = 3,
fade: CGFloat = 40,
@ViewBuilder content: () -> Content
) {
self.speed = speed
self.delay = delay
self.fade = fade
self.content = content()
}
var body: some View {
AutoScroll(
axis: .vertical,
mode: .bounce,
speed: speed,
delay: delay,
fade: fade
) {
content
}
}
}This is all very POC. I like the idea of sharing elements but I think we are sharing too many elements meaning the wrappers are redundant. There are also a lot of elements in here that are shared that are only used on one or the other. So, if this is the correct route, we'd want to do a better job of moving the vertical logic to only Marquee, horizontal to only Ticker, and shared in the middle IF the overlap is reusable. The other version of this is just moving the axis to the Marquee (My first version) but I think that's confusing. Lol hopefully this makes sense! |
|
Hey, apologies for going silent on this one - have a much bigger workload than expected at the moment. I hope to get back to it this weekend :) |
WIP - addresses feedback from #1838
I have played around a lot and realised that probably the user scrolling autonomously is more natural than auto-scroll, espeically when the length of text is (in theory) variable and could be quite dense.
I have introduced the state required to make the Modal focusable and scrollable, borrowing from ScrollIfLargerThanContainer to dynamically measure the modal frame.
The only problem I have is the unreliable disappearing of the Modal - if you are scrolling around the Settings view calmly and in-order, it works great, but there's some discrepancy in SwiftUI's state management where for example if you are clicking the listviewpicker of the video player type and you click the esc key and so no section item is focussed at that time - the learnMoreModal never disappears for any 3 of the options in the Video Player section of the Settings View.
It's a small thing but is just really annoying, meaning that the Modal will stick around while the user scrolls throughout the whole Video Player section depending on their exact combination of swiping in and out of focus for the Modal.
I have a feeling changing line 98 of Form from ".onChange(of: focusedLearnMore != nil) {" to ".onChange(of: focusedLearnMore)" would fix it but as AnyView isn't equatable, i can't see a natural and small way to fix it.