Skip to content

Make LearnMoreModal focusable and scrollable on tvOS#1913

Draft
henryauryn wants to merge 6 commits intojellyfin:mainfrom
henryauryn:scrollable-learn-more-modal
Draft

Make LearnMoreModal focusable and scrollable on tvOS#1913
henryauryn wants to merge 6 commits intojellyfin:mainfrom
henryauryn:scrollable-learn-more-modal

Conversation

@henryauryn
Copy link

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.

@JPKribs JPKribs added enhancement New feature or request tvOS Impacts tvOS labels Feb 15, 2026
@JPKribs JPKribs linked an issue Feb 15, 2026 that may be closed by this pull request
2 tasks
@JPKribs
Copy link
Member

JPKribs commented Feb 15, 2026

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 Player section in SettingsView uses our new learnMoreModal so that would be the place to test this. I've done this for my testing to give me a scrollable amount and all seems well:

    @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

@henryauryn
Copy link
Author

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 Player section in SettingsView uses our new learnMoreModal so that would be the place to test this. I've done this for my testing to give me a scrollable amount and all seems well:

    @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.mov

Yeah 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 :)

@JPKribs
Copy link
Member

JPKribs commented Feb 15, 2026

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?"

@henryauryn
Copy link
Author

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.

@JPKribs
Copy link
Member

JPKribs commented Feb 15, 2026

if it's not functional, it shouldn't be focusable.

I like that thinking! I think you put that into words better than I have been haha.

I'd look at the ScrollIfLargerThanContainerModifier. Specifically, AlternateLayoutView for a static and a scrolling view where we can put the focusable on the secondary view when it's too large for container. Maybe even just a ScrollIfLargerThanContainerModifier argument like:

.scrollIfLargerThanContainer(padding: 100, isFocusable: true)

For changing focus, I would look at the .focusGuide to capture movement from left to right / modal to button. Then, wrap the .focusGuide in something like:

.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

@henryauryn
Copy link
Author

Okay so I've made some changes and I think it's working well.

  • I added a linearGradient mask to the bottom of a scrollable Modal so the user instinctively knows there's content to scroll for; the text I was testing with coincidentally had a perfect sentence end on the final in-view row which made me realise that the user wouldn't have an idea that this modal has more text going on out-of-view and that it is focusable and scrollable.
  • the ScrollIfLarger now calculates the focusable state independentally of its caller by using a passed in FocusState binding - non-scrollable modals now are not focusable at all, with the focusable ones being stylized with the fade out at the bottom (I tried to make it as "Apple-y" as possible)
  • I re-worked the ScrollIfLarger to use viewThatFits that makes it more concise and makes the scroll state tracking redundant
  • Tracking the focus state to direct it back to only its source video player type picker has been impossible to implement here. I have tried a lot of different things but I ran into a lot of issues like the entire Form being a focusSection and having no realistic way to identify only the form item we want to stop the tvOS focus engine from finding the nearest in-line item for whatever scroll position it is on with the modal. Using focus section for things like tagging the Form and saying "left: "Modal"" meant that all the form items were swipable left to the modal, but only one changes the isModalFocused, so the modal was invisible but still focused.

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

@JPKribs
Copy link
Member

JPKribs commented Feb 17, 2026

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!

@henryauryn
Copy link
Author

Cool, thank you, no worries - it's getting on for midnight here in London anyways so no rush, I'll be seeing it tomorrow :)

@JPKribs
Copy link
Member

JPKribs commented Feb 17, 2026

I am seeing what was giving you trouble. This view is primarily vertical and making a horizontal .focusable section creates some weird interactions. I am able to get what I want but it's jankier than I would like. Moving quickly can create headaches especially with focus redirections.

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.mov

The Good

  • Consistent user experience - Left is always details and right is always interaction
  • No focus nonsense to work about and this won't break if we're moving too fast

The Bad

  • Bad for slow readers - Depending on the speed of localizations, some of this text might be in English for non-English speakers so this is more impactful
  • Requires sitting around for the reset if you missing something at the top

Code

Form
//
// 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()
    }
}

@henryauryn
Copy link
Author

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

@JPKribs
Copy link
Member

JPKribs commented Feb 18, 2026

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.

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.

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

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:

https://github.com/jellyfin/Swiftfin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22

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 VideoPlayerManager to a StoredValue when calling that on initial usage.

@JPKribs
Copy link
Member

JPKribs commented Feb 18, 2026

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.mov

From the WebDev world, this looks like horizontal = Marquee and vertical = Ticker. Then Wrap v Bounce for the reset effect. I think there is a world where we capture all the shared logic in an AutoScroll object then use that for both a Marquee and Ticker struct? I'd need to think more about this TBH.

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!

@henryauryn
Copy link
Author

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 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request tvOS Impacts tvOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Focusable & Scrollable ‘LearnMoreModal‘

2 participants