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 :-)
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:
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..lineLimit(1)
and .fixedSize()
, to prevent it from being truncated.VideoCard
. But another way to do it is to use .onGeometryChange
to measure it..onGeometryChange
.alignment: .bottomLeading
for the overlay.min(0, containerWidth - textWidth)
will need to be applied, with animation.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)
}