What You'll Find Here
Yes, I will share the code. If that's all you're after, you can scroll on down. But my purpose in posting on Dev is to document my learning journey. So if you read on, you'll also get my motivations, a glimpse into my thought process, all my failures along the way, and what I learned from creating the MarqueeView.
Milestones
This month I completed my Udacity nanodegree in iOS Development! My final project was a calendar for my mom, whose temporal lobe epilepsy makes regular calendars unintelligible to her. I turned in an "MVP" (Minimum Viable Product) version of the app, as soon as it met all of Udacity's requirements.
Now I'm working on a "MomVP" version; one that I can leave with my mom with some confidence it will help more than it will confuse her. This requires a lot of back and forth, observing her interact with the UI, and adjusting the features so they make sense and are useful to her.
The Challenge
One feature I realized I would need is a way of displaying messages that come and go at prescribed times. For example, "It's night-time right now. You should be in bed," or "It's Johnny's birthday!" At any given time several of these might be strung together. I wanted to display them in one line, regardless of the length. I also do not want her to have to do anything to view the information. This requires scrolling text. I wanted the text to scroll from right to left continuously and smoothly, like a stock ticker.
First Thought
I started off looking for a pre-made solution. I did find a couple of different implementations of this online, but none of them did exactly what I wanted. Typically they achieved the effect by animating an offset. This moves text from right to left, but the challenge comes in bringing the text back onto the screen from the right. Some solutions left a long blank space before starting over, and others only scrolled across one time.
Second Thought
After an exhaustive search (I mean, I must have googled four or five different phrases! 😜) I decided to have a go at coding this myself. I ended up making a solution I like better than any I was able to find, so I'm pretty excited about sharing it with you.
The Long and Winding Road
My first attempt was a loop that repeatedly moved the first character to the end of the string. I used SwiftUI's Timeline view to trigger the next iteration about five times per second. That worked as proof of concept, but was far from smooth-looking.
Animation?
Then I tried adding an animation to the loop: Animate an offset to the left, then move the first character to the end, repeat. This worked, sort of. At first the animation was fading out the old text and fading in the new. But once I got it to actually slide the text... it still looked jerky and annoying!
Fixed-Width Font?
The problem was, letters of differing widths require different offset amounts. So I changed my font to a fixed-width font. Problem solved, right? Almost, however... spaces are their own width and still caused the animation to hiccup. (Plus who wants to be limited to using a fixed-width font anyway?)
Custom Timeline?
So I created a customized timeline for my Timeline view, with time intervals corresponding to the widths of my characters, calculated to keep things moving at a constant speed. Brilliant. This appeared to help... not at all.
Sync Trouble!
The animation was still just as jerky. With some sleuthing I discovered that my time intervals were not syncing up with my characters. I couldn't figure out how to make the string start scrolling exactly when the Timeline view began broadcasting time intervals.
Parameter?
Then it occurred to me I should use the Timeline view's date parameter, the way it was designed to be used (duh). Timeline spits out a date every however often you tell it to, and I hadn't been using it. But if I could determine what text to show and where to offset it based on that date parameter... This was a major change in perspective for me. Instead of trying to actually move the text, I needed to think about where the text should be at a given time and just put it there!
Array of Strings?
At first I used a whole array of strings, one for each character of my message with that character at the front. I used the time parameter to calculate which string to use, then how much to offset that string. But all those stings seemed like overkill, so I simplified, using string slices to calculate a single string for a given time, starting with the right character.
Animation Mode!
I set the Timeline view's timeline to ".animation" - leaving the actual time intervals up to the system. This worked great in my test app! For each Date the Timeline view spit out, I re-calculated the string and offset, and got smooth animation... except...
Stuttering Text!
When I put my MarqueeView into my calendar app, the exact same code exhibited some very odd behavior. Instead of scrolling all the way through, every second or so the message would reset back to the start!
Getting By With a Little Help From My Friends
At this point I was stumped so I took the problem to Saturday's Flock of Swifts meeting (was that really just two days ago?). The problem was, I refresh my content view (the parent view) once per second for other reasons, and even though my MarqueeView did not depend on any state-related inputs, it was getting re-launched once per second along with everything else.
App Window Overlay?
The suggested solution was to attach the MarqueeView to my app window with an overlay. This would put it above the every-second refresh in the hierarchy. And that worked! But...
No Access to the Data!
After the meeting I realized we had only tested that solution using a text literal. But there was no way to get text generated from my app into the view since my text-generating class was not available at the app window level.
Aha!
One final tweak got me there... The actual reason my MarqueeView kept resetting was because I had put all the set-up code in the view's init function. So each time the view was re-initialized, it reset all the variables tracking how the text was supposed to display.
Get Your Business Out of My View!
My solution was to take that business out of the view. Instead I put it into its own class, which I called "MarqueeController." Now in order to scroll some text, I could create a MarqueeController based on that text, then pass that to the view. When the view gets re-booted, it continues to draw values from the controller, which doesn't reset, since it lives outside of the view.
Lesson Learned (Again and Again)
Certain core concepts seem easy to understand but weirdly difficult to put into practice.
I've read a lot about the MVC (Model View Control) principal of separation, and it seems when it comes to defining what goes where, most writers get a little hand-wavy. It's a thing you learn through experience; you have to develop some intuition about it. This little project gave me some of that.
My initial solution only worked for literal text, and only in its own stand-alone app. The model, view, and control were all encapsulated in a single struct.
To integrate my MarqueeView into a larger app, it was necessary to separate the three components.
Model
The text to display is calculated from my data model, an array of calendar events. When the events get updated, the text is updated, and the new text is funneled into the view controller via a published variable.
View
The MarqueeView itself is basically just a text view with modifiers inside a TimelineView. It's the minimum code required to put the text on the screen in an animation context.
Control
The MarqueeController class is the brains behind the whole operation. It takes in a given time and returns the information needed to place the text where it should go at that exact moment.
An instance of MarqueeController works independently from the MarqueeView. This allows the system to destroy and re-create the view as needed. So long as it references the same controller, a new view instance will pick up smoothly where the old one left off.
How to Use the MarqueeView
First, create a MarqueeController instance by passing in a String to be displayed.
let marqueeController = MarqueeController(message: yourMessageHere)
Then place the view by passing in your MarqueeController.
MarqueeView(controller: marqueeController)
That's it. The string yourMessageHere
will scroll forever and ever.
You could also test it out by placing this inside your ContentView:
MarqueeView(controller: MarqueeController(message: "This is a test. This text will scroll smoothly from right to left forever and ever. * "))
Customization
You can change the speed of the scroll by changing the value for let speed
in the MarqueeController.
You can also change the font or font size by making changes to let marqueeFont
in the MarqueeController. Make sure to use UIFont in order for the width calculations to work.
Here's the Code! Enjoy!
//
// MarqueeView.swift
//
// Created by Monty Harper on 12/11/23.
//
import Foundation
import SwiftUI
struct MarqueeView: View {
// Making the controller optional allows you to place the MarqueeView before text is available to display without crashing your app.
var controller: MarqueeController?
init(controller: MarqueeController?) {
self.controller = controller
}
var body: some View {
if let controller = controller {
TimelineView(.animation) {context in
Text(controller.frame(context.date).text)
.padding()
.lineLimit(1)
.font(Font(controller.marqueeFont))
.foregroundColor(.primary)
.offset(x: controller.frame(context.date).offset, y: 0.0)
.background(.white)
.fixedSize(horizontal: true, vertical: false)
.clipped()
}
} else {
Text("There is nothing to display yet...")
}
}
}
class MarqueeController {
let message: String
var characterWidths = [Double]()
var timeMarkers = [TimeInterval]()
var startTime = 0.0
var runningTime = 0.0
let speed = 90.0 // points/second
let marqueeFont = UIFont.systemFont(ofSize: 24, weight: .black) // Using a UIFont because the width can be measured.
init(message: String) {
self.message = message
var text = message
for _ in 0..<message.count {
let first = String(text.removeFirst())
let width = first.size(withAttributes: [.font: marqueeFont]).width
characterWidths.append(width)
runningTime += width/speed
timeMarkers.append(runningTime)
text += first
}
startTime = Date().timeIntervalSince1970
}
// Given a time, return the text and offset that should be displayed at that moment.
func frame(_ date: Date) -> (text: String, offset: Double) {
let time = (date.timeIntervalSince1970 - startTime).truncatingRemainder(dividingBy: runningTime)
let index = (timeMarkers.firstIndex(where: {time < $0}) ?? 0)
let startStringIndex = message.startIndex
let endStringIndex = message.endIndex
let midStringIndex = message.index(message.startIndex, offsetBy: index)
let text = String(message[midStringIndex..<endStringIndex]) + String(message[startStringIndex..<midStringIndex])
let offset = characterWidths[index] * ((timeMarkers[index] - time)/(timeMarkers[index] - (index == 0 ? 0 : timeMarkers[index - 1])) - 1.0)
return(text: text, offset: offset)
}
}