Created
November 13, 2021 18:50
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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