Skip to content

Instantly share code, notes, and snippets.

@kevinbhayes
Created November 13, 2021 18:50
Show Gist options
  • Save kevinbhayes/a8b478274813cc52553479b8492600f3 to your computer and use it in GitHub Desktop.
Save kevinbhayes/a8b478274813cc52553479b8492600f3 to your computer and use it in GitHub Desktop.
A SwiftUI Text View that will automatically scroll a single line of text if the container is not wide enough to display it.
//
// ScrollingTextView.swift
//
// Created by Kevin Hayes on 2021-10-30.
//
import SwiftUI
struct ScrollingTextView: View {
@State private var textOffset: CGFloat = 0.0
@State private var textWidth: CGFloat = 0.0
@State private var viewWidth: CGFloat = 8.0
@State private var viewHeight: CGFloat = 0.0
@State private var scrollingText = ""
@State private var actualAlignment = Alignment.leading
@State private var separatorWidth = 0.0
@State private var isScrolling = false
// when the text is larger than the field, we must double it so we don't scroll gaps
@State private var doubled = false
@State private var undoubledWidth: CGFloat = 0.0
public let textToScroll: String
public let alignment: Alignment
public let pause: Double
public let separator: String
/// ScrollingTextView will display a single line of text, and continuously scroll it if it is too wide to fit in its container.
/// - Parameters:
/// - text: String - The text to display.
/// - separator: String - A separator that will be inserted between the end of the text and the start of the next copy of the text while it is scrolling.
/// - pause: Double - the time to pause between scrolling each copy of the text, in seconds.
/// - alignment: Alignment - the horizontal alignment of the text.
public init(text: String, separator: String = " • ", pause: Double = 1.0, alignment: Alignment = .center) {
self.textToScroll = text
self.separator = separator
self.pause = pause
self.alignment = alignment
}
public var body: some View {
ZStack(alignment: actualAlignment) {
// the text that will scroll if too short for the view
HStack {
Spacer()
.frame(width: actualAlignment == .trailing ? nil : 0)
Text(scrollingText)
.fixedSize()
.padding(.vertical, 4)
.background(TextBackgroundGeometry())
Spacer()
.frame(width: actualAlignment == .leading ? nil : 0)
}
.frame(minWidth: 8, maxWidth: viewWidth, alignment: actualAlignment)
.offset(x: isScrolling ? -(undoubledWidth + separatorWidth) : 0)
.animation(isScrolling ? .linear(duration: 5).delay(pause).repeatForever(autoreverses: false) : .linear(duration: 0.1), value: isScrolling)
// never shown, used to calculate the width of the separator
Text(separator)
.background(GeometryReader { separatorGeometryProxy in
Color.clear
.preference(key: SeparatorWidthKey.self, value: separatorGeometryProxy.size.width)
})
.hidden()
// this view is calculates the width of the view (not the text)
GeometryReader { viewGeometryProxy in
Rectangle()
.foregroundColor(.clear)
.preference(key: ViewWidthKey.self, value: viewGeometryProxy.size.width)
}
}
.frame(height: viewHeight)
.onAppear(perform: {
scrollingText = textToScroll
textOffset = viewWidth
})
.onPreferenceChange(ViewWidthKey.self) { width in
if width > 0 && undoubledWidth > 0 && adjustScrollingText(forViewWidth: width, textWidth: textWidth) {
return
}
DispatchQueue.main.async {
viewWidth = width
textOffset = viewWidth
}
}
.onPreferenceChange(TextSizeKey.self) { size in
if size.width > 0 && doubled == false && undoubledWidth != size.width {
undoubledWidth = size.width
}
if viewWidth > 0 && size.width > 0 && adjustScrollingText(forViewWidth: viewWidth, textWidth: size.width) {
return
}
DispatchQueue.main.async {
viewHeight = size.height
textWidth = size.width
}
}
.onPreferenceChange(SeparatorWidthKey.self, perform: { value in
separatorWidth = value
})
.onChange(of: textToScroll) { newValue in
doubled = false
scrollingText = textToScroll
}
.clipped()
}
/// Calculates whether or not the text must be doubled (to avoid a gap in scrolling)
/// - Parameters:
/// - viewWidth: the width of the view
/// - textWidth: the width of the text that may be required to scroll
/// - Returns: true if the text had to be doubled (or undoubled)
fileprivate func adjustScrollingText(forViewWidth viewWidth: CGFloat, textWidth: CGFloat) -> Bool {
var shouldDouble = false
if undoubledWidth > 0 && undoubledWidth > viewWidth {
DispatchQueue.main.async {
doubled = true
isScrolling = true
actualAlignment = .leading
scrollingText = textToScroll + separator + textToScroll
}
shouldDouble = true
}
else {
DispatchQueue.main.async {
doubled = false
isScrolling = false
actualAlignment = alignment
scrollingText = textToScroll
}
}
return doubled == shouldDouble
}
fileprivate struct SeparatorWidthKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: Value = 0.0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
fileprivate struct ViewWidthKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: Value = 0.0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue() + 8.0
}
}
fileprivate struct TextSizeKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = CGSize.zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
value = CGSize(width: next.width, height: max(value.height, next.height))
}
}
fileprivate struct TextBackgroundGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: TextSizeKey.self, value: geometry.size)
}
}
}
}
struct ScrollingTextView_Previews: PreviewProvider {
static var previews: some View {
ScrollingTextView(text: "Hello, World! Today is a great day!")
.padding(50)
.font(.title)
.foregroundColor(.purple)
.frame(width: 300)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment