Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Tests

# Run once per change, not twice. `on: [push, pull_request]` would fire
# both events whenever a PR branch is pushed (push → `refs/heads/<branch>`,
# pull_request → `refs/pull/<n>/merge`), doubling CI minutes. Restricting
# `push` to `main` means branch pushes only run under the PR; `main` still
# gets CI on direct pushes / merges.
on:
pull_request:
push:
branches: [main]

# Cancel superseded runs on the same branch so stale pushes don't burn billing.
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
# Style and formatting gate. Cheap and runs in parallel with the test job,
# so a style violation surfaces immediately instead of after the suite.
# Configs live in `.swiftlint.yml` and `.swiftformat`; both run with
# --strict so warnings are treated as errors.
lint:
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
timeout-minutes: 2

- name: Install swiftlint and swiftformat
timeout-minutes: 5
run: |
brew update >/dev/null
brew install swiftlint swiftformat
swiftlint --version
swiftformat --version

# Scoped to the library and its tests. `Demo/` is sample code carrying
# deliberately long marquee strings, and is excluded in `.swiftlint.yml`.
- name: SwiftLint
timeout-minutes: 2
run: swiftlint lint --strict --quiet Sources Tests

- name: SwiftFormat
timeout-minutes: 2
run: swiftformat Sources Tests --lint --strict

# Test + coverage gate. `swift test` on the macOS host is what produces the
# coverage profile; the platform build matrix below covers the slices this
# job cannot compile.
test:
# Xcode's package-graph resolver checks Package.swift's tools-version with
# Xcode's built-in SwiftPM, not a separately installed toolchain. This
# manifest requires Swift 6.2, so the image needs Xcode 26+.
runs-on: macos-26
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
timeout-minutes: 2

- uses: maxim-lobanov/setup-xcode@v1
timeout-minutes: 5
with:
xcode-version: latest-stable

- name: Print toolchain
timeout-minutes: 1
run: |
xcodebuild -version
swift --version

- name: Run tests with coverage
timeout-minutes: 10
run: swift test --enable-code-coverage

# SwiftPM emits an indexed `.profdata`, which Codecov cannot read, so it
# has to be converted to LCOV. Paths are derived from
# `swift build --show-bin-path` rather than hard-coded, because the build
# directory is architecture-specific (e.g. `arm64-apple-macosx/debug`).
- name: Export coverage report
timeout-minutes: 2
run: |
set -euo pipefail
bin_dir="$(swift build --show-bin-path)"
profdata="$bin_dir/codecov/default.profdata"
test_bundle="$(find "$bin_dir" -maxdepth 1 -name '*.xctest' -type d -print | head -n 1)"

if [[ ! -f "$profdata" || -z "$test_bundle" ]]; then
echo "::error::Could not find coverage profile or test bundle under $bin_dir." >&2
exit 1
fi

test_binary="$test_bundle/Contents/MacOS/$(basename "$test_bundle" .xctest)"

xcrun llvm-cov export \
-format=lcov \
"$test_binary" \
-instr-profile "$profdata" \
--ignore-filename-regex='(/\.build/|/Tests/)' \
> coverage.lcov

# An empty or headerless report uploads "successfully" and silently
# reports zero coverage, so fail loudly here instead.
if [[ ! -s coverage.lcov ]] || ! grep -q '^SF:' coverage.lcov; then
echo "::error::Generated LCOV report is empty or invalid." >&2
exit 1
fi

echo "Covered files:"
grep '^SF:' coverage.lcov

# The repository is public and the organisation allows tokenless uploads,
# so `CODECOV_TOKEN` is optional; the step still passes it when present.
# `fail_ci_if_error` stays false deliberately: a Codecov outage should not
# turn the Tests badge red when the tests themselves passed.
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
timeout-minutes: 5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.lcov
fail_ci_if_error: false

# Cross-platform compile gate. `swift test` above only ever compiles the
# macOS slice, so a break confined to another platform's SDK would otherwise
# reach main unseen. Building every declared platform is what keeps the
# `platforms:` list in Package.swift honest.
build:
name: Build (${{ matrix.platform }})
runs-on: macos-26
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
platform: [iOS, macOS, tvOS, visionOS, watchOS]
steps:
- uses: actions/checkout@v6
timeout-minutes: 2

- uses: maxim-lobanov/setup-xcode@v1
timeout-minutes: 5
with:
xcode-version: latest-stable

- name: Build for ${{ matrix.platform }}
timeout-minutes: 15
run: |
xcodebuild build \
-scheme MarqueeText \
-destination "generic/platform=${{ matrix.platform }}" \
-configuration Debug \
-skipPackagePluginValidation \
-skipMacroValidation \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ playground.xcworkspace
# .swiftpm

.build/
.swiftpm/

# CocoaPods
#
Expand Down
49 changes: 49 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
included:
- Sources
- Tests

excluded:
- .build
- .swiftpm

opt_in_rules:
- array_init
- closure_end_indentation
- closure_spacing
- collection_alignment
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
- empty_collection_literal
- empty_count
- empty_string
- explicit_init
- fatal_error_message
- first_where
- flatmap_over_map_reduce
- identical_operands
- joined_default_parameter
- last_where
- legacy_multiple
- literal_expression_end_indentation
- modifier_order
- operator_usage_whitespace
- overridden_super_call
- prefer_self_type_over_type_of_self
- redundant_nil_coalescing
- sorted_first_last
- toggle_bool
- unneeded_parentheses_in_closure_argument
- yoda_condition

line_length:
warning: 120
error: 200
ignores_comments: false
ignores_urls: true

identifier_name:
excluded:
- id
- x
- y
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ let package = Package(
.iOS(.v16),
.macOS(.v13),
.tvOS(.v16),
.visionOS(.v1)
.visionOS(.v1),
.watchOS(.v9)
],
products: [
.library(
Expand Down
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# MarqueeText

[![Tests](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml/badge.svg)](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/harflabs/MarqueeText/branch/main/graph/badge.svg)](https://codecov.io/gh/harflabs/MarqueeText)
[![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)
[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fharflabs%2FMarqueeText%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/harflabs/MarqueeText)
[![License](https://img.shields.io/github/license/harflabs/MarqueeText)](LICENSE)

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.

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

## Requirements

- iOS 16.0+
- macOS 13.0+
- tvOS 16.0+
- visionOS 1.0+
- watchOS 9.0+

## Installation

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

```swift
dependencies: [
.package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.1.0")
.package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.2.0")
]
```

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

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

`MarqueeText` is layout-interchangeable with a single line `Text`:

- It reports the same size for every proposal, **including on the first layout pass**, so dropping it into a `List`,
`LazyVStack`, or toolbar never shifts surrounding layout on the following frame.
- A generous height proposal does not stretch it, so it behaves like `Text` — not like `Color` — inside `ZStack`,
overlays, and stacks with taller siblings.
- Text baselines are forwarded, so it lines up in `HStack(alignment: .firstTextBaseline)`.
- Only the horizontal axis is clipped. Glyphs that legitimately paint outside the line box — Arabic diacritics,
emoji, tall accents — render exactly as `Text` renders them.

These guarantees are covered by tests that compare `MarqueeText` against a real `Text` in a hosting view.

### Performance

`MarqueeText` is built to survive long lists.

- **Text that fits costs nothing at rest.** Only overflowing text starts a timeline, so a list of mostly
short labels does no per-frame work at all.
- **Offscreen rows stop animating.** In a `List` or `LazyVStack`, per-frame work stays flat whether the
collection holds 25 rows or 2,500 — only the rows on screen tick.
- **The view measures itself once.** Both the text width and the container width are produced by the layout
and reported through a single weightless probe, so there is no hidden duplicate of the text to lay out and
no second geometry reader.

One caveat worth knowing: a plain `VStack` inside a `ScrollView` builds *every* row, so every overflowing
marquee animates even while scrolled out of sight. Use `List` or `LazyVStack` for long collections, as you
would for any non-trivial row content.

### Custom Timing

```swift
Expand Down Expand Up @@ -93,8 +128,11 @@ MarqueeText("Styled marquee text")

### Accessibility

`MarqueeText` exposes a single accessibility label for the full text. When Reduce Motion is enabled, overflowing
text is shown without the continuous marquee animation.
`MarqueeText` exposes a single accessibility label for the full text, carrying the static text trait.

When Reduce Motion is enabled, overflowing text does not scroll. It truncates with an ellipsis exactly like `Text`,
so the label still reads as deliberately shortened rather than being cut off mid glyph. The full string remains
available to VoiceOver through the accessibility label.

## Testing

Expand All @@ -105,7 +143,8 @@ swift test --enable-code-coverage
```

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

## Examples

Expand Down Expand Up @@ -142,7 +181,6 @@ inputs, and redraw-heavy animation timing.

## Apps Using MarqueeText

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

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

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

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.
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.
22 changes: 22 additions & 0 deletions Sources/MarqueeText/MarqueeConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import SwiftUI

struct MarqueeConfiguration: Equatable {
static let defaultDelay: TimeInterval = 1
static let defaultDuration: TimeInterval = 8
static let defaultSpacing: CGFloat = 50

var delay: TimeInterval
var duration: TimeInterval
var spacing: CGFloat

init(
duration: TimeInterval = Self.defaultDuration,
delay: TimeInterval = Self.defaultDelay,
spacing: CGFloat = Self.defaultSpacing
) {
self.duration = duration.marqueePositive(or: Self.defaultDuration)
self.delay = delay.marqueeNonNegative
self.spacing = spacing.marqueeNonNegative
}
}
21 changes: 21 additions & 0 deletions Sources/MarqueeText/MarqueeContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SwiftUI

enum MarqueeContent: Equatable {
case localized(LocalizedStringResource)
case verbatim(String)

var text: Text {
switch self {
case .localized(let text):
Text(text)
case .verbatim(let text):
Text(verbatim: text)
}
}
}

// The two widths the marquee needs in view state in order to decide whether to scroll.
//
// Both are already known to ``MarqueeSizingLayout``, which hands them back by proposing them as the size
// of a weightless probe subview: width carries the natural text width, height carries the container width.
// Only the widths matter — the view's height comes from the layout, not from view state.
Loading
Loading