Search code examples
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:

    • The left edge of the text should align with the image when the button does not have focus.

    • When the button has focus, the text should scroll to display the hidden portion.

    • The animation should stop once the last word of the text can be seen.

    • The animation should then repeat, if the button still has focus.


    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 don't want the width of the container (the VStack) to be affected by the text. In other words, a wide text banner should not make the container wider than the image. So it works best to show the banner as an overlay.
    • Reserve (vertical) space for the banner using a hidden placeholder.
    • If the banner text might be long then apply .lineLimit(1) and .fixedSize(), to prevent it from being truncated.
    • In your example, you were using a hard-coded width for the container. This may be because you are passing in the container width as a parameter to VideoCard. But another way to do it is to use .onGeometryChange to measure it.
    • Similarly, the size of the text can be measured using .onGeometryChange.
    • The default position of the text can be made bottom-left by using alignment: .bottomLeading for the overlay.
    • To bring the hidden part of the text into view, an x-offset of min(0, containerWidth - textWidth) will need to be applied, with animation.
    • In order for the animation to run at a consistent speed when texts of different widths are displayed, the duration of the animation needs to be computed from the width of the text and the width of the container.
    • If you wanted the text to move back into position immediately when focus is lost, you could use an animation with a duration of 0.
    • The default styling of a button also includes some padding of its own. But it's fine to ignore this, which means the hidden portion will move in by the padding amount before the animation repeats.

    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