0

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

0

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

4 Comments

TYVM for the detailed response! But I want the text's left edge to align with the image's when the button isn't focused, otherwise it should scroll to display the hidden portion. And the animation should stop once the last word of the text can be seen, but then restart from the beginning. To achieve that, I've tried .offset(x: index != focusedIndex || textWidth <= containerWidth ? 0 : containerWidth - textWidth) which works, but the text is overflowing the overlay and displaying over its neighbouring buttons (there are actually many buttons displayed together in a LazyHStack). Thoughts?
@Kenny83 OK, answer updated to work in the way you described. Regarding overflow, it doesn't happen for the example use shown in the answer so I am not sure why it should be a problem. Just make sure that each card is an individual container with its own overlay. However, if there is some other reason why the overflow is happening, try applying .clipped() to the container in the card.
OMFG you are a legend!! I was definitely going to ask about the delays at either end too, because as you said, this is the hardest part of getting it to work. But I didn't even have to ask! Thank you soooooo much! :-D
@Kenny83 You're welcome, thanks for accepting the answer :)

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.