swiftanimationswiftuitvos

How can I animate the scrolling of a SwiftUI Text view based upon a @FocusState variable?


I have been battling with ChatGPT for almost two days, trying to get it to suggest a viable method of scrolling some text across its parent view (a Button) when the parent is focused.

It started off by suggesting the following approach:

import Kingfisher
import SwiftUI

struct VideoCard: View {
    @FocusState private var focusedIndex: Int?

    var body: some View {
        Button {
            // Handle button action
        } label: {
            VStack(spacing: 8) {
                KFImage(URL(string: "https://my-server.com/images/video-thumbnail.png"))
                    .placeholder {
                        ProgressView()
                    }.resizable()
                ZStack {
                    // Text inside a ZStack for smooth scrolling effect
                    GeometryReader { geo in
                        let textWidth = geo.size.width
                        let containerWidth = 480.0

                        Text("This is a cool video!")
                            .frame(width: containerWidth, alignment: .leading)
                            .offset(x: focusedIndex == index ? -(textWidth - containerWidth) : 0) // Scroll left when focused
                            .animation(focusedIndex != index ? .default : Animation.linear(duration: 3.0).repeatForever(autoreverses: false), value: focusedIndex)
                    }
                }
            }
        }.focused($focusedIndex, equals: index)
    }
}

Which didn't work at all! So I asked it to try again multiple times, and it spat out several more versions of the same code, slightly-changed by each prompt (sometimes using .animation on the Text view itself and other times using withAnimation in a separate function), until it finally gave me this:

import Kingfisher
import SwiftUI

struct VideoCard: View {
    @FocusState private var focusedIndex: Int?
    @State var textWidth: CGFloat

    // Same code as above until...
    ZStack {
        Text(text).background(GeometryReader { geometry in
            Color.clear.onAppear { textWidth = geometry.size.width }
        })
        // Same animation code
    }
}

Which worked, but the animation was way too fast, no matter how I tweaked the duration: value. So when I told it that, it suggested that I use a Timer instead, like so:

var textScrollTimer: Timer?

func startScrollingText() {
    let scrollDistance = textWidth - 480.0
    guard scrollDistance > 0 else { return }

    var scrollProgress = 0.0

    // Stop any existing timer
    textScrollTimer?.invalidate()

    // Reset the scroll position
    textScrollPosition.scrollTo(x: 0)

    // Start a timer in 1 second to control the scrolling animation
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        textScrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in
            if scrollDistance > scrollProgress {
                // Incrementally update the scroll position until we've reached the end
                scrollProgress += 2.0
                textScrollPosition.scrollTo(x: scrollProgress)
            } else {
                // Stop the existing timer
                textScrollTimer?.invalidate()

                // Restart the animation with a new timer after 1 second
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    startScrollingText()
                }
            }
        }
    }
}

By this stage I had also moved my Text view into a ScrollView and bound its .scrollPosition() modifier to textScrollPosition.

This mostly worked, but I was getting weird concurrency issues, so now ChatGPT is suggesting that I go back to a withAnimation approach that is practically identical to its second suggestion over a day ago!

I've wasted so much time going around in circles and am so tired right now, I just want a quick and easy way of doing this, whether it involves a ScrollView or offset or whatever, I really don't care as long as it works!

MTIA for any helpful guidance :-)


Solution

  • One reason why your code is not working might be because a GeometryReader is greedy, meaning it consumes all the space it can. So in the first example, the width measured by the GeometryReader is actually the container width, not the text width.

    Anyway, here is one way to get the scrolling working.


    EDIT In your comment, you defined the following requirements:


    To start with, it is important that your main content (the image or video) has a well-defined size. An easy way to check is to add .border(.red) after the image, to make sure it is occupying the space you are expecting. Then:

    You probably want to add a delay to the start and end of the animation, to give the user time to read the text before the animation repeats. This makes the animation a lot more difficult to implement, because adding a delay to an auto-repeating animation will only delay the start, not the end. Using a phase animator is not really an option either, because you only want the animation to happen when the button has focus.

    As a way of getting the staggered animation to work, a .task(id:) modifier can be used. This is essentially a manual implementation of a phased animator, but with the added ability of being able to check that the conditions for animation are satisfied before the animation repeats.

    Here is the updated example to show it working. I tested using a static image instead of KFImage, I hope it works in the same way when you put your original image back in.

    struct VideoCard: View {
        let text: String
        let index: Int
        @FocusState private var focusedIndex: Int?
        @State private var containerWidth = CGFloat.zero
        @State private var textWidth = CGFloat.zero
        @State private var xOffset = CGFloat.zero
    
        private var animationDuration: TimeInterval {
            max(0, textWidth - containerWidth) / 80.0
        }
    
        private var scrolledOffset: CGFloat {
            min(0, containerWidth - textWidth)
        }
    
        var body: some View {
            Button {
                // Handle button action
            } label: {
                VStack(spacing: 8) {
    //                KFImage(URL(string: "https://my-server.com/images/video-thumbnail.png"))
    //                    .placeholder {
    //                        ProgressView()
    //                    }
                    Image(.image2)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 480)
    //                    .border(.red)
    
                    Text("X").hidden() // vertical placeholder
                }
                .overlay(alignment: .bottomLeading) {
                    Text(text)
                        .lineLimit(1)
                        .fixedSize()
                        .onGeometryChange(for: CGFloat.self) { proxy in
                            proxy.size.width
                        } action: { width in
                            textWidth = width
                        }
                        .offset(x: xOffset)
                        .animation(xOffset < 0 ? .linear(duration: animationDuration) : .default, value: xOffset)
                        .onChange(of: focusedIndex) { oldVal, newVal in
                            xOffset = newVal == index ? scrolledOffset : 0
                        }
                        .task(id: xOffset) {
                            if textWidth > containerWidth {
                                if xOffset < 0 {
                                    try? await Task.sleep(for: .seconds(animationDuration + 1))
                                    xOffset = 0
                                } else {
                                    try? await Task.sleep(for: .seconds(1))
                                    if focusedIndex == index {
                                        xOffset = scrolledOffset
                                    }
                                }
                            }
                        }
                }
                .onGeometryChange(for: CGFloat.self) { proxy in
                    proxy.size.width
                } action: { width in
                    containerWidth = width
                }
            }
            .focused($focusedIndex, equals: index)
        }
    }
    

    Example use:

    LazyHStack(spacing: 100) {
        VideoCard(text: "The quick brown fox jumps over the lazy dog", index: 1)
        VideoCard(text: "This is a cool video!", index: 2)
    }
    

    Animation