Search code examples
iosswiftswiftuiswiftui-animation

Swift UI - Animating long line of text to scroll left and right


I would like to animate long single line of text to display the entire text by scrolling left and right on repeat.

I tried to do this myself using the following code, but it just scrolls to the end of the text when the view is loaded. It doesn't scroll back.

private func scrollViewForLongName(
    _ name: String) -> some View {
        let topID = 1
        let bottomID = 29
        
        return ScrollViewReader { proxy in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack {
                    Text(name)
                        .id(topID)
                        .onAppear {
                            let baseAnimation = Animation.easeInOut(duration: 1)
                            let repeated = baseAnimation.repeatForever(autoreverses: true)
                            
                            DispatchQueue.main.async {
                                withAnimation(repeated) {
                                    proxy.scrollTo(bottomID)
                                }
                            }
                        }
                    
                    Text(" ")
                        .id(bottomID)
                }
            }
        }
    }

Solution

  • Here is a solution for you. In your title and question you said you were looking for an animation that scrolls left and right on repeat, so I assume you want to see it scroll across, then rewind and play again. This is a different kind of animation to a normal ticker, which usually just keeps looping in the same direction. However, if it is a ticker that you want then you can find solutions by searching SO, or you can adapt this one (it would be simpler).

    This solution scrolls across, then rewinds at a faster speed and repeats the animation again. To do this it uses the technique I posted as an answer to Change Reverse Animation Speed SwiftUI. The width of the text is established by displaying it as the base view, then the animation is applied as an overlay over the top of it. The base view is masked out by applying a background to the overlay.

    EDIT: Code updated to apply the same delay at begin and end.
    EDIT(2): Added checks to prevent division by zero when the string is short.

    struct OffsetModifier: ViewModifier, Animatable {
    
        private let maxOffset: CGFloat
        private let rewindSpeedFactor: Int
        private let endWaitFraction: CGFloat
        private var progress: CGFloat
    
        // The progress value at which the end wait begins
        private let endWaitThreshold: CGFloat
    
        // The progress value at which rewind begins
        private let rewindThreshold: CGFloat
    
        init(
            maxOffset: CGFloat,
            rewindSpeedFactor: Int = 4,
            endWaitFraction: CGFloat = 0,
            progress: CGFloat
        ) {
            self.maxOffset = maxOffset
            self.rewindSpeedFactor = rewindSpeedFactor
            self.endWaitFraction = endWaitFraction
            self.progress = progress
    
            // Compute the thresholds for waiting and for rewinding
            let rewindFraction = (CGFloat(1) - endWaitFraction) / CGFloat(rewindSpeedFactor + 1)
            self.rewindThreshold = CGFloat(1) - rewindFraction
            self.endWaitThreshold = CGFloat(1) - rewindFraction - endWaitFraction
        }
    
        /// Implementation of protocol property
        var animatableData: CGFloat {
            get { progress }
            set { progress = newValue }
        }
    
        var xOffset: CGFloat {
            let fraction: CGFloat
            if progress > rewindThreshold {
                fraction = endWaitThreshold - ((progress - rewindThreshold) * CGFloat(rewindSpeedFactor))
            } else {
                fraction = min(progress, endWaitThreshold)
            }
            return endWaitThreshold > 0 ? (fraction / endWaitThreshold) * maxOffset : 0
        }
    
        func body(content: Content) -> some View {
            content.offset(x: xOffset)
        }
    }
    
    struct RewindingTextTicker: View {
    
        let textKey: String
        let viewWidth: CGFloat
        let beginEndDelaySecs: TimeInterval
    
        init(
            textKey: String,
            viewWidth: CGFloat,
            beginEndDelaySecs: TimeInterval = 1.0
        ) {
            self.textKey = textKey
            self.viewWidth = viewWidth
            self.beginEndDelaySecs = beginEndDelaySecs
        }
    
        let pixelsPerSec = 100
        @State private var progress = CGFloat.zero
    
        private func duration(width: CGFloat) -> TimeInterval {
            (TimeInterval(max(width, 0)) / TimeInterval(pixelsPerSec)) + beginEndDelaySecs
        }
    
        private func endWaitFraction(textWidth: CGFloat) -> CGFloat {
            let totalDuration = duration(width: textWidth - viewWidth)
            return totalDuration > 0 ? beginEndDelaySecs / totalDuration : 0
        }
    
        var body: some View {
    
            // Display the full text on one line.
            // This establishes the width that is needed
            Text(LocalizedStringKey(textKey))
                .lineLimit(1)
                .fixedSize(horizontal: true, vertical: true)
    
                // Perform the animation in an overlay
                .overlay(
                    GeometryReader { proxy in
                        Text(LocalizedStringKey(textKey))
                            .modifier(
                                OffsetModifier(
                                    maxOffset: viewWidth - proxy.size.width,
                                    endWaitFraction: endWaitFraction(textWidth: proxy.size.width),
                                    progress: progress
                                )
                            )
                            .animation(
                                .linear(duration: duration(width: proxy.size.width - viewWidth))
                                .delay(beginEndDelaySecs)
                                .repeatForever(autoreverses: false),
                                value: progress
                            )
                            // Mask out the base view
                            .background(Color(UIColor.systemBackground))
                    }
                )
                .onAppear { progress = 1.0 }
        }
    }
    
    struct ContentView: View {
    
        private let fullText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
    
        var body: some View {
            VStack {
                GeometryReader { proxy in
                    RewindingTextTicker(
                        textKey: fullText,
                        viewWidth: proxy.size.width
                    )
                }
                .frame(height: 50)
            }
            .padding(.horizontal, 50)
        }
    }
    

    Here it is running. There is actually a delay at begin and end of scroll, but the delay does not show in the animated gif.
    Example