There is a simple way to fix this issue, or a convoluted way.
The simple way is to define an accent color for the app. This color then gets used for the native scrolling title. Credit to candyline for providing this solution, see their answer here (+1).
The convoluted way is to implement your own scrolling for the Text version of the title.
The rest of this answer provides a solution for the convoluted approach, in case you don't want to change your accent color. It also allows you to style the title in other ways, for example, by using a different font.
When you examine the way the string is scrolled natively, you will notice that the head of the string comes into view while the tail is still scrolling away to the left. So there are effectively two copies of the string being displayed at once. This might also explain, why scrolling does not come for free when a custom View is used as the title.
The same effect can be achieved by using a ScrollView to show the Text:
- Use an
HStack as container for two copies of the Text.
- Give each copy of the
Text a unique id.
- Use
.scrollPosition with anchor: .leading, to allow programmatic scrolling.
- On appear, scroll to the second of the
Text copies, with animation.
- The animation duration can be computed from the title length.
You might want to insert a small delay before the scrolling movement begins, to allow the start of the title to be read before it starts scrolling away. I found that it didn't work to add a delay to the animation. However, if a .task is used instead of .onAppear then it makes it easy to include a short sleep.
Here is how this functionality can be encapsulated as a standalone View:
struct ScrollingTextTitle: View {
let title: String
@State private var scrollPosition: Int?
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 20) {
Text(title).id(0)
Text(title).id(1)
}
}
.scrollPosition(id: $scrollPosition, anchor: .leading)
.scrollIndicators(.hidden)
.scrollDisabled(true)
.task {
try? await Task.sleep(for: .seconds(0.2))
if !Task.isCancelled {
withAnimation(.linear(duration: Double(title.count) / 4.0)) {
scrollPosition = 1
}
}
}
// Tweak the vertical position
.padding(.bottom, -3)
}
}
It can now be plugged into your original example:
case .text:
content
.navigationTitle {
ScrollingTextTitle(title: title) // 👈 here
.foregroundStyle(.blue)
}

EDIT The version above works fine for cases where you know that the title will need to scroll. However, if the title may be short enough to be displayed without truncation then the duplicate text and scrolling should be avoided.
The updated version below is more general-purpose. It works for both long and short titles by determining automatically whether scrolling is needed or not.
It works as follows:
- Another copy of the text is shown in the background using
alignment: .trailing.
- The modifier
.fixedWidth() is applied to the background copy, to prevent truncation.
- The width of both the
ScrollView and the background Text is measured using .onGeometryChange.
- Opacity is used to control whether the scrolled version or the background version is visible, according to whether scrolling is needed or not.
- Scrolling is only needed if the background version is wider than the scrolled version.
In the earlier version above, the animation duration was being computed from the number of characters in the string. Since the width of the text is now being measured precisely, the speed of scroll can be made more consistent by computing the animation duration using the exact text width.
struct ScrollingTextTitle: View {
let title: String
private let scrollRatePointsPerSec = 30.0
private let scrollViewSpacing: CGFloat = 20.0
@State private var scrollPosition: Int?
@State private var scrollViewWidth: CGFloat?
@State private var textWidth: CGFloat?
private var isScrollingNeeded: Bool? {
if let scrollViewWidth, let textWidth {
scrollViewWidth < textWidth
} else {
nil
}
}
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: scrollViewSpacing) {
Text(title).id(0)
Text(title).id(1)
}
}
.scrollPosition(id: $scrollPosition, anchor: .leading)
.scrollIndicators(.hidden)
.scrollDisabled(true)
.opacity(isScrollingNeeded ?? false ? 1 : 0)
.onGeometryChange(for: CGFloat.self, of: \.size.width) { width in
scrollViewWidth = width
}
.background(alignment: .trailing) {
Text(title)
.fixedSize()
.opacity(isScrollingNeeded ?? true ? 0 : 1)
.onGeometryChange(for: CGFloat.self, of: \.size.width) { width in
textWidth = width
}
}
.task {
try? await Task.sleep(for: .seconds(0.2))
if !Task.isCancelled, let textWidth, isScrollingNeeded ?? false {
let duration = (textWidth + scrollViewSpacing) / scrollRatePointsPerSec
withAnimation(.linear(duration: duration)) {
scrollPosition = 1
}
}
}
// Tweak the vertical position
.padding(.bottom, -3)
}
}