SwiftUI letter scroll animation
In this article we are going to create a custom SwiftUI text component that animates while the container view is scrolling.
The final concept we have created looks like this:
In particular, we will focus on the title view, that removes and adds letters of the next title while we scroll.
For this component we need two input parameters:
- the list of titles
- the scroll offset of the container scrollview
struct ScrollingLetters: View {
let titles: [String]
let containerScrollPercent: CGFloat
var body: some View {
// TODO: implementation
}
}
To achieve the effect we are aiming for we need to split the titles strings into separate letters and wrap them in an HStack
.
Applying this concept to the first title only we end up with:
var body: some View {
HStack(spacing: 0) {
let letters = titles[0].map { String($0) }
ForEach(letters, id: \.self) { letter in
Text("\(letter)")
}
}
}
Now the fun starts :)
We not only want to show the first title. Depending on the container scroll offset, we want to show part of the next one. But how much of the next title?
Let's say the scroll offset is 1.6
.
Note: The scroll offset is a number between 0 and (number of titles - 1). If we had 6 titles, the scroll offset would be a number between 0 and 5.
This means that the current scroll offset (1.6
) is between title[1]
and title[2]
. In fact 60%
between title[1]
and title[2]
. In this scenario we show the last40%
of title[1]
and the first 60% percent of title[2]
As we can scroll back and forth and the titles might not have the same length we need a stable letter count and apply the percent to this count. For that reason, we take the max letter count between title1 and title2.
let index0 = Int(floor(containerScrollPercent))
let index1 = Int(ceil(containerScrollPercent))
let titlePercent = containerScrollPercent.truncatingRemainder(dividingBy: 1)
let title0 = titles[index0]
let title1 = titles[index1]
let maxLetterCount = max(title0.count, title1.count)
Note: The truncatingRemainder gives us the percent between the two titles that are currently visible. If containerScrollPercent is 3.2, the truncatingRemainder is 0.2. Which means 20% between title3 and title4.
Applying the percent to this max letter count we know how many letters have to be added/removed from the two titles.
let letterIndex = Int((titlePercent * Double(maxLetterCount)).rounded())
Now, with this information, we can obtain the actual start and end indices that we want to display
var startLetterIndex = 0
for wordIndex in 0..<index0 {
startLetterIndex += titles[wordIndex].count
}
var endLetterIndex = startLetterIndex
endLetterIndex += title0.count - 1 + min(letterIndex, title1.count)
startLetterIndex += min(letterIndex, title0.count)
With this in place we finally can show the correct letters for each scroll position
var body: some View {
HStack(spacing: 0) {
let indices = curentLetterIndices
ForEach(indices.start...indices.end, id: \.self) { index in
Text("\(allLetters[index]")
}
}
Nice, but...this look very clunky.
Let's add some animation. First we add an implicit animation to the view that animates the letters when the scrollview offset updates.
var body: some View {
HStack(spacing: 0) {
// ...
}
.animation(.spring, value: containerScrollPercent)
}
Much better.
Note: To be able to animate from one state to the next one we need stable ids. In our case it works because the indices we use in the ForEach loop are unique. If instead of unique indices we would use letters in the loop, we would need tu assign an unique id to each letter to make sure the system knows how to transition between different states.
The last step is to add a transition
to the letters to control how they appear/disappear when they are added/removed. We could for example say we want them to appear from the top and disappear moving down.
ForEach(indices.start...indices.end, id: \.self) { index in
Text("\(allLetters[index])")
.transition(.push(from: .top))
}
In our sneakers concept we use an asymmetric offset transition
.
In the final code implementation, we have also added the option to customize the transition and be able to inject any type of transition.