Skip to content

Commit e13b7d4

Browse files
authored
Fix layout parity with Text, cut per-row cost, and add CI
MarqueeText reported a hard-coded 20pt height on the first layout pass, so any font whose line height was not 20pt resized one frame after appearing — row jitter and scroll drift in List and LazyVStack. Sizing now comes from the layout, which is correct on the first pass and never changes as measurement settles. Also fixes: - Height followed the proposal instead of the text, so a generous proposal stretched the view like Color rather than Text. - clipped() cut glyph overhang. Arabic diacritics, emoji and tall accents paint outside the line box and Text renders them, so clipping is now horizontal only. - Overflowing text that could not scroll (Reduce Motion) was clipped mid-glyph with no truncation indicator. It now truncates like Text. - Baselines were not forwarded, so firstTextBaseline stacks misaligned. - Content identity used String(describing:) on LocalizedStringResource, a Mirror dump, on every body evaluation. - The measurement preference could silently drop a real measurement, and could not be cleared once the text became empty. Performance: the view laid the text out twice, once hidden purely to measure it, and ran a second geometry reader for the container width. Both values are already known to the layout, so it now reports them by proposing them as the size of a single weightless probe. Measured over 200 rows: 0.755 -> 0.577 ms/row for overflowing text, 0.429 -> 0.264 ms/row for text that fits. Declares watchOS 9 support, which previously worked only by SPM inferring the floor from the APIs used. Adds a Tests workflow (lint, coverage, and a build matrix across all five declared platforms), Codecov configuration, a SwiftLint config, and badges. Source and tests are split into focused files.
1 parent 129da72 commit e13b7d4

25 files changed

Lines changed: 1631 additions & 1055 deletions

.github/workflows/test.yml

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
name: Tests
2+
3+
# Run once per change, not twice. `on: [push, pull_request]` would fire
4+
# both events whenever a PR branch is pushed (push → `refs/heads/<branch>`,
5+
# pull_request → `refs/pull/<n>/merge`), doubling CI minutes. Restricting
6+
# `push` to `main` means branch pushes only run under the PR; `main` still
7+
# gets CI on direct pushes / merges.
8+
on:
9+
pull_request:
10+
push:
11+
branches: [main]
12+
13+
# Cancel superseded runs on the same branch so stale pushes don't burn billing.
14+
concurrency:
15+
group: tests-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
# Style and formatting gate. Cheap and runs in parallel with the test job,
20+
# so a style violation surfaces immediately instead of after the suite.
21+
# Configs live in `.swiftlint.yml` and `.swiftformat`; both run with
22+
# --strict so warnings are treated as errors.
23+
lint:
24+
runs-on: macos-26
25+
timeout-minutes: 10
26+
steps:
27+
- uses: actions/checkout@v6
28+
timeout-minutes: 2
29+
30+
- name: Install swiftlint and swiftformat
31+
timeout-minutes: 5
32+
run: |
33+
brew update >/dev/null
34+
brew install swiftlint swiftformat
35+
swiftlint --version
36+
swiftformat --version
37+
38+
# Scoped to the library and its tests. `Demo/` is sample code carrying
39+
# deliberately long marquee strings, and is excluded in `.swiftlint.yml`.
40+
- name: SwiftLint
41+
timeout-minutes: 2
42+
run: swiftlint lint --strict --quiet Sources Tests
43+
44+
- name: SwiftFormat
45+
timeout-minutes: 2
46+
run: swiftformat Sources Tests --lint --strict
47+
48+
# Test + coverage gate. `swift test` on the macOS host is what produces the
49+
# coverage profile; the platform build matrix below covers the slices this
50+
# job cannot compile.
51+
test:
52+
# Xcode's package-graph resolver checks Package.swift's tools-version with
53+
# Xcode's built-in SwiftPM, not a separately installed toolchain. This
54+
# manifest requires Swift 6.2, so the image needs Xcode 26+.
55+
runs-on: macos-26
56+
timeout-minutes: 15
57+
steps:
58+
- uses: actions/checkout@v6
59+
timeout-minutes: 2
60+
61+
- uses: maxim-lobanov/setup-xcode@v1
62+
timeout-minutes: 5
63+
with:
64+
xcode-version: latest-stable
65+
66+
- name: Print toolchain
67+
timeout-minutes: 1
68+
run: |
69+
xcodebuild -version
70+
swift --version
71+
72+
- name: Run tests with coverage
73+
timeout-minutes: 10
74+
run: swift test --enable-code-coverage
75+
76+
# SwiftPM emits an indexed `.profdata`, which Codecov cannot read, so it
77+
# has to be converted to LCOV. Paths are derived from
78+
# `swift build --show-bin-path` rather than hard-coded, because the build
79+
# directory is architecture-specific (e.g. `arm64-apple-macosx/debug`).
80+
- name: Export coverage report
81+
timeout-minutes: 2
82+
run: |
83+
set -euo pipefail
84+
bin_dir="$(swift build --show-bin-path)"
85+
profdata="$bin_dir/codecov/default.profdata"
86+
test_bundle="$(find "$bin_dir" -maxdepth 1 -name '*.xctest' -type d -print | head -n 1)"
87+
88+
if [[ ! -f "$profdata" || -z "$test_bundle" ]]; then
89+
echo "::error::Could not find coverage profile or test bundle under $bin_dir." >&2
90+
exit 1
91+
fi
92+
93+
test_binary="$test_bundle/Contents/MacOS/$(basename "$test_bundle" .xctest)"
94+
95+
xcrun llvm-cov export \
96+
-format=lcov \
97+
"$test_binary" \
98+
-instr-profile "$profdata" \
99+
--ignore-filename-regex='(/\.build/|/Tests/)' \
100+
> coverage.lcov
101+
102+
# An empty or headerless report uploads "successfully" and silently
103+
# reports zero coverage, so fail loudly here instead.
104+
if [[ ! -s coverage.lcov ]] || ! grep -q '^SF:' coverage.lcov; then
105+
echo "::error::Generated LCOV report is empty or invalid." >&2
106+
exit 1
107+
fi
108+
109+
echo "Covered files:"
110+
grep '^SF:' coverage.lcov
111+
112+
# The repository is public and the organisation allows tokenless uploads,
113+
# so `CODECOV_TOKEN` is optional; the step still passes it when present.
114+
# `fail_ci_if_error` stays false deliberately: a Codecov outage should not
115+
# turn the Tests badge red when the tests themselves passed.
116+
- name: Upload coverage reports to Codecov
117+
uses: codecov/codecov-action@v5
118+
timeout-minutes: 5
119+
with:
120+
token: ${{ secrets.CODECOV_TOKEN }}
121+
files: coverage.lcov
122+
fail_ci_if_error: false
123+
124+
# Cross-platform compile gate. `swift test` above only ever compiles the
125+
# macOS slice, so a break confined to another platform's SDK would otherwise
126+
# reach main unseen. Building every declared platform is what keeps the
127+
# `platforms:` list in Package.swift honest.
128+
build:
129+
name: Build (${{ matrix.platform }})
130+
runs-on: macos-26
131+
timeout-minutes: 20
132+
strategy:
133+
fail-fast: false
134+
matrix:
135+
platform: [iOS, macOS, tvOS, visionOS, watchOS]
136+
steps:
137+
- uses: actions/checkout@v6
138+
timeout-minutes: 2
139+
140+
- uses: maxim-lobanov/setup-xcode@v1
141+
timeout-minutes: 5
142+
with:
143+
xcode-version: latest-stable
144+
145+
- name: Build for ${{ matrix.platform }}
146+
timeout-minutes: 15
147+
run: |
148+
xcodebuild build \
149+
-scheme MarqueeText \
150+
-destination "generic/platform=${{ matrix.platform }}" \
151+
-configuration Debug \
152+
-skipPackagePluginValidation \
153+
-skipMacroValidation \
154+
CODE_SIGN_IDENTITY="" \
155+
CODE_SIGNING_REQUIRED=NO \
156+
CODE_SIGNING_ALLOWED=NO

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ playground.xcworkspace
3030
# .swiftpm
3131

3232
.build/
33+
.swiftpm/
3334

3435
# CocoaPods
3536
#

.swiftlint.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
included:
2+
- Sources
3+
- Tests
4+
5+
excluded:
6+
- .build
7+
- .swiftpm
8+
9+
opt_in_rules:
10+
- array_init
11+
- closure_end_indentation
12+
- closure_spacing
13+
- collection_alignment
14+
- contains_over_filter_count
15+
- contains_over_filter_is_empty
16+
- contains_over_first_not_nil
17+
- empty_collection_literal
18+
- empty_count
19+
- empty_string
20+
- explicit_init
21+
- fatal_error_message
22+
- first_where
23+
- flatmap_over_map_reduce
24+
- identical_operands
25+
- joined_default_parameter
26+
- last_where
27+
- legacy_multiple
28+
- literal_expression_end_indentation
29+
- modifier_order
30+
- operator_usage_whitespace
31+
- overridden_super_call
32+
- prefer_self_type_over_type_of_self
33+
- redundant_nil_coalescing
34+
- sorted_first_last
35+
- toggle_bool
36+
- unneeded_parentheses_in_closure_argument
37+
- yoda_condition
38+
39+
line_length:
40+
warning: 120
41+
error: 200
42+
ignores_comments: false
43+
ignores_urls: true
44+
45+
identifier_name:
46+
excluded:
47+
- id
48+
- x
49+
- y

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ let package = Package(
88
.iOS(.v16),
99
.macOS(.v13),
1010
.tvOS(.v16),
11-
.visionOS(.v1)
11+
.visionOS(.v1),
12+
.watchOS(.v9)
1213
],
1314
products: [
1415
.library(

README.md

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# MarqueeText
22

3+
[![Tests](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml/badge.svg)](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml)
4+
[![codecov](https://codecov.io/gh/harflabs/MarqueeText/branch/main/graph/badge.svg)](https://codecov.io/gh/harflabs/MarqueeText)
5+
[![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fharflabs%2FMarqueeText%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/harflabs/MarqueeText)
6+
[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fharflabs%2FMarqueeText%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/harflabs/MarqueeText)
7+
[![License](https://img.shields.io/github/license/harflabs/MarqueeText)](LICENSE)
8+
39
A lightweight SwiftUI component that automatically creates marquee scrolling animations when text overflows its container. Perfect for music players, news tickers, status displays, and more.
410

511
## Features
@@ -9,14 +15,15 @@ A lightweight SwiftUI component that automatically creates marquee scrolling ani
915
- 🎨 **SwiftUI Native** - Built with pure SwiftUI
1016
- ♿️ **Accessible** - VoiceOver-friendly labels with Reduce Motion support
1117
- ↔️ **Localizable** - Supports `LocalizedStringResource` and right-to-left layouts
12-
- 📱 **Multi-Platform** - iOS, macOS, tvOS, and visionOS
18+
- 📱 **Multi-Platform** - iOS, macOS, tvOS, visionOS, and watchOS
1319

1420
## Requirements
1521

1622
- iOS 16.0+
1723
- macOS 13.0+
1824
- tvOS 16.0+
1925
- visionOS 1.0+
26+
- watchOS 9.0+
2027

2128
## Installation
2229

@@ -26,7 +33,7 @@ Add the following to your `Package.swift` file:
2633

2734
```swift
2835
dependencies: [
29-
.package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.1.0")
36+
.package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.2.0")
3037
]
3138
```
3239

@@ -64,6 +71,34 @@ the marquee responsive in lists, stacks, compact controls, and during device rot
6471

6572
Right-to-left layout direction mirrors the marquee alignment and scroll direction.
6673

74+
`MarqueeText` is layout-interchangeable with a single line `Text`:
75+
76+
- It reports the same size for every proposal, **including on the first layout pass**, so dropping it into a `List`,
77+
`LazyVStack`, or toolbar never shifts surrounding layout on the following frame.
78+
- A generous height proposal does not stretch it, so it behaves like `Text` — not like `Color` — inside `ZStack`,
79+
overlays, and stacks with taller siblings.
80+
- Text baselines are forwarded, so it lines up in `HStack(alignment: .firstTextBaseline)`.
81+
- Only the horizontal axis is clipped. Glyphs that legitimately paint outside the line box — Arabic diacritics,
82+
emoji, tall accents — render exactly as `Text` renders them.
83+
84+
These guarantees are covered by tests that compare `MarqueeText` against a real `Text` in a hosting view.
85+
86+
### Performance
87+
88+
`MarqueeText` is built to survive long lists.
89+
90+
- **Text that fits costs nothing at rest.** Only overflowing text starts a timeline, so a list of mostly
91+
short labels does no per-frame work at all.
92+
- **Offscreen rows stop animating.** In a `List` or `LazyVStack`, per-frame work stays flat whether the
93+
collection holds 25 rows or 2,500 — only the rows on screen tick.
94+
- **The view measures itself once.** Both the text width and the container width are produced by the layout
95+
and reported through a single weightless probe, so there is no hidden duplicate of the text to lay out and
96+
no second geometry reader.
97+
98+
One caveat worth knowing: a plain `VStack` inside a `ScrollView` builds *every* row, so every overflowing
99+
marquee animates even while scrolled out of sight. Use `List` or `LazyVStack` for long collections, as you
100+
would for any non-trivial row content.
101+
67102
### Custom Timing
68103

69104
```swift
@@ -93,8 +128,11 @@ MarqueeText("Styled marquee text")
93128

94129
### Accessibility
95130

96-
`MarqueeText` exposes a single accessibility label for the full text. When Reduce Motion is enabled, overflowing
97-
text is shown without the continuous marquee animation.
131+
`MarqueeText` exposes a single accessibility label for the full text, carrying the static text trait.
132+
133+
When Reduce Motion is enabled, overflowing text does not scroll. It truncates with an ellipsis exactly like `Text`,
134+
so the label still reads as deliberately shortened rather than being cut off mid glyph. The full string remains
135+
available to VoiceOver through the accessibility label.
98136

99137
## Testing
100138

@@ -105,7 +143,8 @@ swift test --enable-code-coverage
105143
```
106144

107145
The test suite covers overflow detection, text and layout updates, right-to-left layout, Reduce Motion, invalid sizing
108-
inputs, and redraw-heavy animation timing.
146+
inputs, redraw-heavy animation timing, seamless loop continuity, and size and baseline parity with `Text` measured
147+
through a real hosting view.
109148

110149
## Examples
111150

@@ -142,7 +181,6 @@ inputs, and redraw-heavy animation timing.
142181

143182
## Apps Using MarqueeText
144183

145-
- [Casti - Your Personalized Podcasts, Powered by AI](https://apps.apple.com/app/id6746376736)
146184
- [Tilfaz - Live & On-Demand TV](https://apps.apple.com/app/id1668359578)
147185

148186
*Add your app here! Submit a pull request to include your app.*
@@ -159,4 +197,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
159197

160198
This library is built by [Harf Labs](https://harflabs.com), a software development company that creates solutions for real problems.
161199

162-
If you like this project and need help with your own software projects, we'd love to hear from you! [Get in touch](https://harflabs.com/en/contact) and let's build something amazing together.
200+
If you like this project and need help with your own software projects, we'd love to hear from you! [Get in touch](https://harflabs.com/en/#contact) and let's build something amazing together.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
struct MarqueeConfiguration: Equatable {
5+
static let defaultDelay: TimeInterval = 1
6+
static let defaultDuration: TimeInterval = 8
7+
static let defaultSpacing: CGFloat = 50
8+
9+
var delay: TimeInterval
10+
var duration: TimeInterval
11+
var spacing: CGFloat
12+
13+
init(
14+
duration: TimeInterval = Self.defaultDuration,
15+
delay: TimeInterval = Self.defaultDelay,
16+
spacing: CGFloat = Self.defaultSpacing
17+
) {
18+
self.duration = duration.marqueePositive(or: Self.defaultDuration)
19+
self.delay = delay.marqueeNonNegative
20+
self.spacing = spacing.marqueeNonNegative
21+
}
22+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import SwiftUI
2+
3+
enum MarqueeContent: Equatable {
4+
case localized(LocalizedStringResource)
5+
case verbatim(String)
6+
7+
var text: Text {
8+
switch self {
9+
case .localized(let text):
10+
Text(text)
11+
case .verbatim(let text):
12+
Text(verbatim: text)
13+
}
14+
}
15+
}
16+
17+
// The two widths the marquee needs in view state in order to decide whether to scroll.
18+
//
19+
// Both are already known to ``MarqueeSizingLayout``, which hands them back by proposing them as the size
20+
// of a weightless probe subview: width carries the natural text width, height carries the container width.
21+
// Only the widths matter — the view's height comes from the layout, not from view state.

0 commit comments

Comments
 (0)